---
title: "Your own annotation look — Vue"
description: "Draw annotations with your own components, react to hover, show cards next to them, and make them interactive."
framework: "Vue"
source: "https://www.embedpdf.com/docs/headless/vue/annotations/custom-rendering"
---

# Your own annotation look

The annotation layer draws each annotation the way the PDF describes it. You can draw some of them
with your own component instead: a note that looks like your app's comment bubble, a stamp
that shows live data from your server.

This changes the screen only. The PDF keeps the annotation's own look, so other PDF apps,
printing and downloads show the original:

| Where                               | What people see                 |
| :---------------------------------- | :------------------------------ |
| Your viewer                         | Your component                  |
| The saved PDF, other apps, printing | The annotation's own appearance |

## Its look, or UI on it

Ask whether it should zoom and turn with the PDF:

| You want                                                                            | Use                                            | It                                                                                       |
| :---------------------------------------------------------------------------------- | :--------------------------------------------- | :--------------------------------------------------------------------------------------- |
| A different look: a comment bubble instead of the note icon, a stamp with live data | A [renderer](#draw-an-annotation-your-way)     | Is part of the page: it zooms and turns with it, and sits under the selection handles    |
| A mark that stays on the annotation: a ✓, a status, a count of replies              | [Pinned anchored UI](#pin-ui-to-an-annotation) | Floats over the pages, upright and the same size at every zoom, exactly where you put it |
| A card or a menu next to it                                                         | [Anchored UI](#show-a-card-on-hover)           | Floats over the pages, and moves to stay in view                                         |

## Draw an annotation your way

Give the layer a list of **renderers**. Each one says which
annotations it draws and with which component:

**`renderers.vue`**

```vue
<script setup lang="ts">
import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/vue/annotation';
import CommentBubble from './CommentBubble.vue';

const renderers: AnnotationRenderer[] = [
  { for: (annotation) => annotation.subtype === 'text', component: CommentBubble },
];
</script>

<template>
  <AnnotationLayer :renderers />
</template>
```

`for`
gets the annotation, with all its fields, so you can pick by kind, name, author or anything else. The first renderer that
matches wins; an annotation no renderer matches is drawn as usual.

Your component draws into a **frame**: a box the layer places, turns and scales exactly like
the annotation's own drawing, also while it's dragged, resized or turned. Fill it, with `width` and
`height` at `100%`, and draw at the annotation's 100% size: the layer scales your drawing with the
page, text and borders included, as it does the PDF's own:

**`bubble.vue`**

```vue
<!-- CommentBubble.vue -->
<script setup lang="ts">
import type { AnnotationRendererProps } from '@embedpdf/vue/annotation';

defineProps<AnnotationRendererProps>();
</script>

<template>
  <!-- It fills its frame (`width` and `height` 100% in your CSS), so the selection
       outline and the click area match it, and it turns with the annotation. -->
  <div :class="['bubble', { 'bubble--hover': hovered }]">
    {{ annotation.author?.[0] ?? '?' }}
  </div>
</template>
```

The selection outline, the click area and the hover all use the frame, so a look that fills it
matches them. A note keeps its size on screen as people zoom in and stays upright when the page is
turned, and so does a look that fills its frame. To make the bubble bigger, make the note bigger: a
size in points is a third more in pixels at 100% zoom, so a 24-point note is 32 pixels.

Your component gets:

| Prop          | What it is                                                                                                                                                                                                                                                             |
| :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `annotation`  | The annotation, as `get()` returns it.                                                                                                                                                                                                                                 |
| `frame`       | The box you draw into: its `width` and `height` in pixels at the annotation's 100% size, its `rotation` on screen in degrees clockwise, and the `scale` the layer draws it at. Turn your content by `-rotation` to keep it upright, such as a label on a turned stamp. |
| `native`      | The layer's own drawing of it, filling the frame. Render it to keep the original look and add to it.                                                                                                                                                                   |
| `appearance`  | The engine's picture of the annotation as an image (`url`), or `null`. `native` already draws it in place.                                                                                                                                                             |
| `hovered`     | True while the pointer is over it.                                                                                                                                                                                                                                     |
| `selected`    | True while it's selected, such as to make room for the selection handles.                                                                                                                                                                                              |
| `interactive` | True when what you draw takes the pointer (see [below](#take-over-the-pointer)).                                                                                                                                                                                       |

**`App.vue`**

```vue
<script setup lang="ts">
import { ref } from 'vue';
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 { interactionPlugin } from '@embedpdf/vue/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import type { AnnotationRenderer } from '@embedpdf/vue/annotation';
import { localEngine } from '@embedpdf/engine';
import AddNotes from './AddNotes.vue';
import CommentBubble from './CommentBubble.vue';
import TurnButton from './TurnButton.vue';

import '../renderers.css';

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

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Defined once, here: the layer registers each entry.
const RENDERERS: AnnotationRenderer[] = [
  { for: (annotation) => annotation.subtype === 'text', component: CommentBubble },
];
const NONE: AnnotationRenderer[] = [];

const mine = ref(true);
</script>

<template>
  <Viewer
    :engine="engine"
    :plugins="plugins"
    :identity="{ userId: 'u_381', displayName: 'Dana Smith' }"
    :initial-documents="[{ source: ebook }]"
  >
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <AddNotes />
      <div class="toolbar">
        <div class="segmented" role="group" aria-label="Look">
          <button type="button" :aria-pressed="mine" @click="mine = true">Your look</button>
          <button type="button" :aria-pressed="!mine" @click="mine = false">The PDF's look</button>
        </div>
        <TurnButton />
      </div>
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <AnnotationLayer :renderers="mine ? RENDERERS : NONE" />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`renderers.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --text: #3b4b7a;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
  --bubble: #7c3aed;
  --bubble-hover: #5b21b6;
}

/* The controls, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

/* One choice of a few: the pressed button is the current one */
.segmented {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 3px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 11px;
}

.segmented button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 6px 12px;
  cursor: pointer;
  white-space: nowrap;
}

.segmented button:hover {
  color: var(--ink);
}

.segmented button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--surface);
  box-shadow: 0 1px 2px rgb(7 32 76 / 0.1);
}

.segmented button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* A note as a comment bubble: the author's initials, filling its frame */
.bubble {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: 700;
  color: var(--surface);
  background: var(--bubble);
  border: 2px solid var(--surface);
  border-radius: 50% 50% 50% 4px;
  box-shadow: 0 2px 6px rgb(7 32 76 / 0.3);
}

.bubble--hover {
  background: var(--bubble-hover);
}

/* Turns the view */
.button {
  font: inherit;
  font-size: 12.5px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 12px;
  cursor: pointer;
  white-space: nowrap;
}

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

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

/* 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);
}
```

**`AddNotes.vue`**

```vue
<script setup lang="ts">
import { computed, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import { useAnnotation, useAnnotationState } from '@embedpdf/vue/annotation';

// On load: two notes on the cover, 24 points square: 32 pixels at 100%.
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const pages = usePageList();
const cover = computed(() => pages.value[0]?.ref);
let added = false;

watch(
  [ready, cover],
  ([isReady, page]) => {
    if (!isReady || !page || added) return;
    added = true;
    void annotation.create(page, {
      subtype: 'text',
      rect: { x: 470, y: 228, width: 24, height: 24 },
      contents: 'Can we shorten the title?',
      color: '#facc15',
    });
    void annotation.create(page, {
      subtype: 'text',
      rect: { x: 280, y: 516, width: 24, height: 24 },
      contents: 'Add the co-author',
      color: '#facc15',
    });
  },
  { immediate: true },
);
</script>

<template></template>
```

**`CommentBubble.vue`**

```vue
<script setup lang="ts">
import { computed } from 'vue';
import type { AnnotationRendererProps } from '@embedpdf/vue/annotation';

// A note drawn as your app's comment bubble, the author's initials in it. It
// fills its frame, so the selection outline and the click area match it, and
// it turns with the annotation: a note stays upright on a turned page.
const props = defineProps<AnnotationRendererProps>();

const initials = computed(() =>
  (props.annotation.author ?? '?')
    .split(' ')
    .map((word) => word[0])
    .join(''),
);
</script>

<template>
  <div :class="['bubble', { 'bubble--hover': hovered }]">{{ initials }}</div>
</template>
```

**`TurnButton.vue`**

```vue
<script setup lang="ts">
import { useStage } from '@embedpdf/vue/stage';

// A note keeps its size on screen and stays upright: turn the view to see it.
const stage = useStage();
</script>

<template>
  <button type="button" class="button" @click="stage.rotateViewBy(90)">Turn the view</button>
</template>
```

> **Try it:** the notes are bubbles with their author's initials. Click one: the selection fits it.
> Press **Turn the view**: they stay upright, like the PDF's own notes. Switch to **The PDF's look**:
> that's what the saved PDF shows.

People can still select, move, resize and delete the annotation: your component draws, and the
viewer keeps handling the pointer. That's also why it gets no clicks of its own. For that, see
[Take over the pointer](#take-over-the-pointer).

## Show a card on hover

A hover card sits next to an annotation while the pointer is over it. It needs two things: the
annotation under the pointer, and an anchor that keeps a card attached to it:

**`hover-card.vue`**

```vue
<!-- StampCard.vue, for the Stage's #overlay slot -->
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { useAnnotationAnchor, useAnnotationState } from '@embedpdf/vue/annotation';

const { hovered } = useAnnotationState(); // the annotation under the pointer, or null
const anchor = useAnnotationAnchor(() => hovered.value?.ref ?? null);
</script>

<template>
  <Anchored v-if="hovered?.subtype === 'stamp'" :anchor placement="top">
    <div class="card">Approved by {{ hovered.author }}</div>
  </Anchored>
</template>
```

**`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 { interactionPlugin } from '@embedpdf/vue/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import { localEngine } from '@embedpdf/engine';
import AddAnnotations from './AddAnnotations.vue';
import HoverCard from './HoverCard.vue';

import '../hover-card.css';

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

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

<template>
  <Viewer
    :engine="engine"
    :plugins="plugins"
    :identity="{ userId: 'u_381', displayName: 'Dana Smith' }"
    :initial-documents="[{ source: ebook }]"
  >
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <AddAnnotations />
      <p class="hint">Point at a note or the green rectangle</p>
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <AnnotationLayer />
        </template>
        <template #overlay><HoverCard /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`hover-card.css`**

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

/* The card next to the annotation under the pointer */
.card {
  max-width: 220px;
  padding: 10px 12px;
  font-size: 13px;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 10px 28px -10px rgb(7 32 76 / 0.3);
}

.card strong {
  color: var(--ink);
}

.card p {
  margin: 4px 0 0;
}

.hint {
  margin: 0 0 14px;
  font-size: 13px;
  color: var(--muted);
}

/* 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);
}
```

**`AddAnnotations.vue`**

```vue
<script setup lang="ts">
import { computed, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import { useAnnotation, useAnnotationState } from '@embedpdf/vue/annotation';

// On load: two notes and a rectangle on the cover, each with a comment.
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const pages = usePageList();
const cover = computed(() => pages.value[0]?.ref);
let added = false;

watch(
  [ready, cover],
  ([isReady, page]) => {
    if (!isReady || !page || added) return;
    added = true;
    void annotation.create(page, {
      subtype: 'text',
      rect: { x: 470, y: 232, width: 20, height: 20 },
      contents: 'Can we shorten the title?',
      color: '#facc15',
    });
    void annotation.create(page, {
      subtype: 'text',
      rect: { x: 280, y: 520, width: 20, height: 20 },
      contents: 'Add the co-author',
      color: '#facc15',
    });
    void annotation.create(page, {
      subtype: 'square',
      box: { x: 96, y: 376, width: 360, height: 118 },
      contents: 'This subtitle reads well',
      color: '#30a46c',
      strokeWidth: 3,
    });
  },
  { immediate: true },
);
</script>

<template></template>
```

**`HoverCard.vue`**

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { useAnnotationAnchor, useAnnotationState } from '@embedpdf/vue/annotation';

// A card next to the annotation under the pointer: who wrote it, and what.
const { hovered } = useAnnotationState(); // the annotation under the pointer, or null
const anchor = useAnnotationAnchor(() => hovered.value?.ref ?? null);
</script>

<template>
  <Anchored v-if="hovered?.contents" :anchor="anchor" placement="top">
    <div class="card">
      <strong>{{ hovered.author ?? 'Someone' }}</strong>
      <p>{{ hovered.contents }}</p>
    </div>
  </Anchored>
</template>
```

Mount it in the Stage's `#overlay`
slot, like a menu. The card stays next to the annotation while the user scrolls and
zooms, and while the annotation is moved.

`useAnnotationAnchor(ref)` works for any annotation, not only the hovered one: see the next section.

## Pin UI to an annotation

A ✓ on an approved stamp, a status under it, a count of replies next to a note: UI that belongs to
one annotation and stays on it. `pinned` keeps it exactly where you put it. It doesn't flip or move to
stay in view, and it scrolls away with the annotation:

**`pinned.vue`**

```vue
<!-- ApprovalStatus.vue -->
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { annotationKey, useAnnotationAnchor, type Annotation } from '@embedpdf/vue/annotation';
import { useApproval } from './approvals'; // your own data

const props = defineProps<{ stamp: Annotation }>();

// Pinned under the stamp: it stays there while the stamp moves, and scrolls away with it.
const anchor = useAnnotationAnchor(() => props.stamp.ref);
const approval = useApproval(() => annotationKey(props.stamp.ref));
</script>

<template>
  <Anchored :anchor="anchor" placement="bottom" pinned>
    <div class="status">
      {{ approval.signedOff ? 'Signed off' : 'Waiting' }}
      <button @click="approval.toggle">{{ approval.signedOff ? 'Undo' : 'Sign off' }}</button>
    </div>
  </Anchored>
</template>
```

Mount one for each annotation that needs it in the Stage's `#overlay` slot,
from `useAnnotationList()`. Anchored UI on a page that isn't on screen isn't drawn, so a document
with hundreds of them costs only the ones in view.

**`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 { interactionPlugin } from '@embedpdf/vue/interaction';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import { localEngine } from '@embedpdf/engine';
import AddStamps from './AddStamps.vue';
import Approvals from './Approvals.vue';

import '../stamp-status.css';

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

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

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <AddStamps />
      <p class="hint">Sign a stamp off, then move it: its status goes with it.</p>
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <AnnotationLayer />
        </template>
        <template #overlay><Approvals /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`stamp-status.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --text: #3b4b7a;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
  --waiting: #f5a524;
  --done: #30a46c;
}

/* What to try, above the pages */
.hint {
  margin: 0 0 14px;
  font-size: 13px;
  color: var(--muted);
}

/* Your status under a stamp, and its button */
.status {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 5px 5px 10px;
  font-size: 12px;
  font-weight: 600;
  color: var(--ink);
  white-space: nowrap;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: 0 6px 18px rgb(7 32 76 / 0.22);
}

.dot {
  width: 9px;
  height: 9px;
  background: var(--waiting);
  border-radius: 50%;
}

.dot--done {
  background: var(--done);
}

.sign {
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--surface);
  background: var(--done);
  border: 0;
  border-radius: 999px;
  padding: 4px 10px;
  cursor: pointer;
}

.sign--undo {
  color: var(--ink);
  background: var(--tint);
}

.sign:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The check on a signed-off stamp's corner */
.check {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  font-size: 13px;
  font-weight: 700;
  color: var(--surface);
  background: var(--done);
  border: 2px solid var(--surface);
  border-radius: 50%;
  box-shadow: 0 2px 6px rgb(7 32 76 / 0.3);
}

/* 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);
}
```

**`AddStamps.vue`**

```vue
<script setup lang="ts">
import { computed, watch } from 'vue';
import { usePageList } from '@embedpdf/vue/runtime';
import { useAnnotation, useAnnotationState } from '@embedpdf/vue/annotation';

// On load: two approval stamps on the cover, one signed off. The second sits
// at the bottom, so its status hangs over the next page.
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const pages = usePageList();
const cover = computed(() => pages.value[0]?.ref);
let added = false;

watch(
  [ready, cover],
  ([isReady, page]) => {
    if (!isReady || !page || added) return;
    added = true;
    void approvedPicture().then(async (appearance) => {
      await annotation.create(
        page,
        {
          subtype: 'stamp',
          box: { x: 330, y: 470, width: 180, height: 60 },
          name: 'approval-budget',
        },
        { appearance },
      );
      await annotation.create(
        page,
        {
          subtype: 'stamp',
          box: { x: 96, y: 712, width: 180, height: 60 },
          name: 'approval-contract',
        },
        { appearance },
      );
    });
  },
  { immediate: true },
);

// The stamp's picture, drawn on a canvas so the example needs no image file.
function approvedPicture(): Promise<Blob> {
  const canvas = document.createElement('canvas');
  canvas.width = 360;
  canvas.height = 120;
  const context = canvas.getContext('2d')!;
  context.strokeStyle = '#30a46c';
  context.lineWidth = 10;
  context.strokeRect(5, 5, 350, 110);
  context.fillStyle = '#30a46c';
  context.font = 'bold 54px sans-serif';
  context.textAlign = 'center';
  context.textBaseline = 'middle';
  context.fillText('APPROVED', 180, 64);
  return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png'));
}
</script>

<template></template>
```

**`Approvals.vue`**

```vue
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { useAnnotationList } from '@embedpdf/vue/annotation';
import type { Annotation } from '@embedpdf/vue/annotation';
import ApprovalStatus from './ApprovalStatus.vue';

// Every approval stamp's status, mounted in the Stage's #overlay slot. Your own
// data for each stamp is kept by its name.
const stamps = useAnnotationList({ subtype: 'stamp' });
const signedOff = reactive<Record<string, boolean>>({ 'approval-budget': true });

// An approval stamp's name, which the stamp keeps in every PDF app; null for any other annotation.
const approvalName = (annotation: Annotation): string | null =>
  annotation.subtype === 'stamp' && annotation.name?.startsWith('approval-')
    ? annotation.name
    : null;

const approvals = computed(() =>
  stamps.value.flatMap((stamp) => {
    const name = approvalName(stamp);
    return name ? [{ name, stamp }] : [];
  }),
);
</script>

<template>
  <ApprovalStatus
    v-for="{ name, stamp } in approvals"
    :key="name"
    :stamp="stamp"
    :signed-off="signedOff[name] ?? false"
    @toggle="signedOff[name] = !signedOff[name]"
  />
</template>
```

**`ApprovalStatus.vue`**

```vue
<script setup lang="ts">
import { Anchored } from '@embedpdf/vue/anchored';
import { useAnnotationAnchor } from '@embedpdf/vue/annotation';
import type { Annotation } from '@embedpdf/vue/annotation';

// One stamp's status, pinned to it: a check on its corner once it's signed off,
// and a status with a button under it. Both stay there while the stamp moves.
const props = defineProps<{ stamp: Annotation; signedOff: boolean }>();
defineEmits<{ toggle: [] }>();

const anchor = useAnnotationAnchor(() => props.stamp.ref);
</script>

<template>
  <Anchored v-if="signedOff" :anchor="anchor" placement="top-end" :gap="-12" pinned>
    <span class="check">✓</span>
  </Anchored>
  <Anchored :anchor="anchor" placement="bottom" :gap="8" pinned>
    <div class="status">
      <span :class="['dot', { 'dot--done': signedOff }]" />
      {{ signedOff ? 'Signed off' : 'Waiting' }}
      <button type="button" :class="['sign', { 'sign--undo': signedOff }]" @click="$emit('toggle')">
        {{ signedOff ? 'Undo' : 'Sign off' }}
      </button>
    </div>
  </Anchored>
</template>
```

> **Try it:** sign the second stamp off, then select a stamp and move it: its status goes with it. The
> second stamp's status hangs over the next page, and stays on top.

1. The stamps are placed like any stamp, so the PDF holds the real ones; see [Stamps](stamps).
2. Each one is picked out by its `name`, which the stamp keeps in every PDF app.
3. The status sits under the stamp with `placement="bottom"`, and the ✓ on its corner with
   `placement="top-end"` and a negative `gap`. See [Where it goes](../ui/anchored#where-it-goes).
4. The stamp keeps its own look and stays selectable: the buttons are next to it, not in it.

To find your own data for an annotation, key it by `annotationKey(annotation.ref)`, which stays the
same after the document is saved and opened again, or by its `name` when you gave it one.

## Take over the pointer

Sometimes your component must take clicks itself: buttons, links, a text field. Mark the
renderer `interactive`:

**`interactive.vue`**

```vue
<script setup lang="ts">
import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/vue/annotation';
import ApprovalWidget from './ApprovalWidget.vue';

const renderers: AnnotationRenderer[] = [
  {
    for: (annotation) => annotation.subtype === 'stamp' && annotation.name === 'Approved',
    component: ApprovalWidget,
    interactive: true,
  },
];
</script>

<template>
  <AnnotationLayer :renderers />
</template>
```

This is for when the annotation itself is the control, such as a stamp you tick like a checkbox. For
buttons next to an annotation, [pin them](#pin-ui-to-an-annotation) instead: the annotation stays
selectable.

While a renderer is interactive, the pointer belongs to your component, and people can't select,
move or delete that annotation with it. Most apps want both: interactive while reading, editable
while editing. Pass a function, and the viewer asks it whenever it matters:

**`interactive-when.vue`**

```vue
<script setup lang="ts">
import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/vue/annotation';
import ApprovalWidget from './ApprovalWidget.vue';

const renderers: AnnotationRenderer[] = [
  {
    for: (annotation) => annotation.subtype === 'stamp' && annotation.name === 'Approved',
    component: ApprovalWidget,
    interactive: ({ toolId }) => toolId === 'pan', // interactive in reading mode only
  },
];
</script>

<template>
  <AnnotationLayer :renderers />
</template>
```

## Good to know

### Drawing at the size on screen

A renderer with `scale: false` draws at the frame's size on screen instead, and sizes its text and
borders itself from `frame.scale`. Use it for something that must lay itself out at full resolution,
such as a canvas.

### Hover cards people can use

A card closes when the pointer leaves the annotation, so the pointer can't reach a button in it.
Keep the card open while the pointer is over the card itself, for example with a short delay
before it closes and a `pointerenter` on the card that cancels it.

### Form fields

A form field's widgets aren't annotations: they're the form's, and the `<FormLayer>` draws them, so
your renderers never get them. See [Forms and annotations](../forms#forms-and-annotations).

### Other layers

A renderer replaces the drawing inside the annotation layer. Anything that isn't tied to one
annotation, such as a comments sidebar, belongs in your own UI, fed by `useAnnotationList()`.
