---
title: "Attachments — Vue"
description: "List the files a PDF carries, save them, let people attach and remove files, and go to a file pinned to a page."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/documents/attachments"
---

# Attachments

A PDF can carry other files: a spreadsheet with the numbers behind a report, the original of a
scanned contract. They sit in two places: the document's own attachments, and file attachment
annotations, a file pinned to a spot on a page. Register `attachmentPlugin()` to list both as one
list:

**`attachment-list.vue`**

```vue
<script setup lang="ts">
import { displayNameOf, useAttachment, useAttachmentState } from '@embedpdf/vue/attachment';
import type { AttachedFile } from '@embedpdf/vue/attachment';
import { saveFile } from '@embedpdf/vue/runtime';

const attachment = useAttachment();
const { files, canDownload } = useAttachmentState();

async function save(file: AttachedFile) {
  const { bytes, name, mimeType } = await attachment.download(file);
  saveFile(bytes, name, mimeType ?? undefined);
}
</script>

<template>
  <ul>
    <li v-for="file in files" :key="file.id">
      <bdi>{{ displayNameOf(file) }}</bdi>
      <button :disabled="!canDownload" @click="save(file)">Save</button>
    </li>
  </ul>
</template>
```

What `useAttachmentState()` gives you updates by itself: after your own changes, and on a shared
document, after someone else's. Nothing is read when a document opens: the attachments load the
first time something asks for them. The files on the pages come from the
[annotation plugin](https://www.embedpdf.com/docs/headless/vue/annotations), when you've registered it.

## Show the files

`files` lists the document's attachments first, in the order the document keeps them (by name),
then the files on its pages, in page order. Each file has its `name`, `mimeType`, `description`,
`size`, `checksum`, `createdAt` and `modifiedAt` (`null` when the PDF doesn't say), an `id` for
your list's keys, and its `home`:

- `{ kind: 'document', ref }`: one of the document's attachments, `ref` its name in the document.
- `{ kind: 'page', ref }`: a file attachment annotation; `ref.page` is its page.

Show a name with `displayNameOf(file)`, and put it in a `<bdi>`. A name can hide characters that
make it look like another (`invoice.pdf` that is really `invoice.exe`); `displayNameOf` takes them
out, and the `<bdi>` keeps a name in another script from turning your row around.

`status` says whether the attachments haven't been asked for yet (`'idle'`), are loading, are
there (`'ready'`), or couldn't be read.

## Save a file

`download(file)` returns the file's bytes, its name ready to save under (`displayNameOf`'s) and
its type. Hand them to `saveFile()` to save them as a browser download:

```ts
const { bytes, name, mimeType } = await attachment.download(file);
saveFile(bytes, name, mimeType ?? undefined);
```

Save a file; never open it in a new tab or a frame of your page. A file opened there runs with your
page's origin, so an HTML attachment could act as your page.

Saving takes a file out of the document, so it needs `doc.download`: check `canDownload`. A file
too large to unpack in the browser is refused (see [Large files](#large-files)).

## Attach files

`add(files)` attaches files as one change: one step to undo. A `File` the user picked brings its
name and type; bytes need a `name`:

```ts
await attachment.add([{ data: picked }, { data: bytes, name: 'figures.csv' }]);
```

Names are unique in a document: a name it has already gets a number, `notes (2).txt`, so nothing
is replaced. The files show at once, `isPending(file)` while the engine saves them, and `add()`
resolves with them. Check `canAdd` first.

## Remove a file

`remove(file)` takes a file away, as one step to undo: an undo brings back the same file, with its
bytes and dates. A file on a page goes with its annotation, through the annotation plugin.
`canRemove(file)` says whether this one may go.

**`App.vue`**

```vue
<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { attachmentPlugin } from '@embedpdf/vue/attachment';
import { localEngine } from '@embedpdf/engine';
import Files from './Files.vue';

import '../files.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), attachmentPlugin()];

const welcome = async (): Promise<OpenInput> => {
  const response = await fetch('/pdfs/welcome.pdf');
  return { kind: 'bytes', id: 'welcome', bytes: new Uint8Array(await response.arrayBuffer()) };
};
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <div class="layout">
        <Files />
        <Stage class="stage">
          <template #page><RenderLayer /></template>
        </Stage>
      </div>
    </DocumentGate>
  </Viewer>
</template>
```

**`files.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #3858e9;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The files beside the pages */
.layout {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 14px;
}

.files {
  display: flex;
  flex-direction: column;
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: 0 1px 2px var(--shadow);
  overflow: hidden;
}

.files-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 10px 10px 14px;
  border-bottom: 1px solid var(--line);
}

.files-title {
  margin: 0;
  font-size: 13px;
  font-weight: 650;
  color: var(--ink);
}

.empty {
  margin: 0;
  padding: 18px 14px;
  font-size: 12.5px;
  color: var(--muted);
}

/* One row per file: its name and size save it; a file still being added is faded */
.list {
  margin: 0;
  padding: 6px;
  list-style: none;
  overflow-y: auto;
}

.row {
  display: flex;
  align-items: center;
  border-radius: 8px;
}

.row:hover {
  background: var(--tint);
}

.row[data-pending] {
  opacity: 0.55;
}

.row-name {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  text-align: start;
  color: var(--ink);
  background: none;
  border: 0;
  padding: 6px 10px;
  cursor: pointer;
}

.row-name bdi {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row-size {
  font-size: 11.5px;
  color: var(--muted);
}

.row-remove {
  font: inherit;
  font-size: 16px;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: 6px;
  padding: 4px 8px;
  cursor: pointer;
  opacity: 0;
}

.row:hover .row-remove,
.row-remove:focus-visible {
  opacity: 1;
}

.row-remove:hover {
  color: var(--ink);
  background: var(--line);
}

.button {
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 6px 12px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button.primary {
  color: #ffffff;
  background: var(--accent);
  border-color: var(--accent);
}

.button:focus-within,
.row-name:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.button[data-disabled],
.row-name:disabled,
.row-remove:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

**`Files.vue`**

```vue
<!-- The files the document carries: attach some, save one, remove one. -->
<script setup lang="ts">
import { saveFile } from '@embedpdf/vue/runtime';
import { displayNameOf, useAttachment, useAttachmentState } from '@embedpdf/vue/attachment';
import type { AttachedFile } from '@embedpdf/vue/attachment';

const attachment = useAttachment();
const { files, canAdd, canDownload } = useAttachmentState();

/** A file's size, as people read it. */
const sizeOf = (bytes: number | null) =>
  bytes === null ? '' : bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;

async function save(file: AttachedFile) {
  const { bytes, name, mimeType } = await attachment.download(file);
  saveFile(bytes, name, mimeType ?? undefined);
}

function attach(event: Event) {
  const input = event.currentTarget as HTMLInputElement;
  const picked = Array.from(input.files ?? []);
  input.value = '';
  if (picked.length > 0) void attachment.add(picked.map((data) => ({ data })));
}
</script>

<template>
  <aside class="files">
    <header class="files-header">
      <h3 class="files-title">Files</h3>
      <label class="button primary" :data-disabled="!canAdd || undefined">
        Attach
        <input type="file" multiple hidden :disabled="!canAdd" @change="attach" />
      </label>
    </header>
    <p v-if="files.length === 0" class="empty">Attach a file to carry it inside the document.</p>
    <ul v-else class="list">
      <li
        v-for="file in files"
        :key="file.id"
        class="row"
        :data-pending="attachment.isPending(file) || undefined"
      >
        <button type="button" class="row-name" :disabled="!canDownload" @click="save(file)">
          <bdi>{{ displayNameOf(file) }}</bdi>
          <span class="row-size">{{ sizeOf(file.size) }}</span>
        </button>
        <button
          type="button"
          class="row-remove"
          :aria-label="`Remove ${displayNameOf(file)}`"
          :disabled="!attachment.canRemove(file)"
          @click="attachment.remove(file)"
        >
          ×
        </button>
      </li>
    </ul>
  </aside>
</template>
```

## Go to a page's file

`reveal(file)` selects a page's file attachment annotation and brings it into view, through the
[stage](https://www.embedpdf.com/docs/headless/vue/viewing/stage) when you've registered it. It does nothing for one of the
document's attachments, which isn't on a page.

## Select files

`select(files)` makes files the selection, and `selected` lists them. The selection is the
reader's: show it, and act on it with your own buttons and menus.

```ts
attachment.select([file]);
await attachment.remove(selected[0]);
```

## Methods

Everything `useAttachment()` gives you.

| Method            | What it does                                                                          |
| :---------------- | :------------------------------------------------------------------------------------ |
| `get(id)`         | One file by its `id`, or `null`.                                                      |
| `isPending(file)` | Whether a change to it waits for the engine. [Attach files](#attach-files)            |
| `canRemove(file)` | Whether this file may go. [Remove a file](#remove-a-file)                             |
| `select(files)`   | Makes these files the selection; `[]` clears it. [Select files](#select-files)        |
| `add(files)`      | Attaches files as one change. Resolves with the new files.                            |
| `remove(file)`    | Takes a file away, as one change.                                                     |
| `download(file)`  | Resolves `{ bytes, name, mimeType }`, ready to save. [Save a file](#save-a-file)      |
| `reveal(file)`    | Goes to a page's file and selects it. [Go to a page's file](#go-to-a-pages-file)      |
| `ensureLoaded()`  | Reads the attachments, unless they're loaded or loading. Resolves once they're there. |
| `refresh()`       | Reads the attachments again. Rarely needed: they stay up to date by themselves.       |

Every method that returns a promise also takes a `signal` in its options, to cancel it.

## State

Everything `useAttachmentState()` gives you, as refs: your template updates when a value changes. The method in the last column reads the same value once.

| Field         | What it is                                                                                      | Read once       |
| :------------ | :---------------------------------------------------------------------------------------------- | :-------------- |
| `files`       | Every file, the document's then the pages', with your changes shown before the engine answers   | `list()`        |
| `selected`    | The selected files, in the order they were selected                                             | `getSelected()` |
| `status`      | `'idle'` until something asks for them, then `'loading'`, `'ready'`, `'forbidden'` or `'error'` | `getStatus()`   |
| `canAdd`      | Whether attaching and removing files is allowed                                                 | `canAdd()`      |
| `canDownload` | Whether saving files is allowed                                                                 | `canDownload()` |

## Events

**`attachment-changed.vue`**

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { useAttachmentEvent } from '@embedpdf/vue/attachment';

const notice = ref('');

useAttachmentEvent(
  (attachment) => attachment.onChanged,
  ({ type, files, origin }) => {
    if (origin.kind === 'remote') notice.value = `Someone ${type} ${files.length} file(s)`;
  },
);
</script>

<template>
  <p role="status">{{ notice }}</p>
</template>
```

| Event        | Fires when                                                                                                                           | Carries                   |
| :----------- | :----------------------------------------------------------------------------------------------------------------------------------- | :------------------------ |
| `onChanged`  | The document's attachments were added, removed or brought back by an undo, by you or by someone else                                 | `type`, `files`, `origin` |
| `onResynced` | The attachments were read from the file: the first time they're asked for, after `refresh()`, or to catch up after a lost connection | `files`                   |

`type` is `'added'`, `'removed'` or `'restored'`; for a removal, `files` are the files as they
were. A file on a page is an annotation: its changes are the annotation plugin's events.

## Permissions

| Permission               | What it allows                                  | Check                      |
| :----------------------- | :---------------------------------------------- | :------------------------- |
| `doc.open`               | Listing the files                               | none: it opened            |
| `doc.download`           | Saving a file: it leaves the document           | `attachment.canDownload()` |
| `doc.attachments.modify` | Attaching files and removing the document's own | `attachment.canAdd()`      |

Removing a file on a page is deleting its annotation, so the annotation's own permission decides:
`canRemove(file)` asks it.

## Good to know

### Large files

The browser holds a file it saves in memory, so the local engine refuses a file that unpacks to
more than 256 MB. `download()` rejects with `invalid-input`, and the error's `details.limit` names
the limit. A file's own size can't be trusted for this: a small compressed file can unpack to
gigabytes.

### Removed files and saving

Removing a file takes it out of the document as it is now; an incremental save still contains the
version you opened. When a file must really be gone, save with `mode: 'rewrite'`. See
[Saving](https://www.embedpdf.com/docs/headless/vue/documents/saving).

### The ready-made viewer

The viewer's Attachments panel is built on this plugin. See
[Panels](https://www.embedpdf.com/docs/viewer/vue/customize/panels).
