---
title: "Coordinates — Vue"
description: "EmbedPDF has one way to say where something is: points from the page's top-left corner, y going down. Pixels only come in when you draw."
framework: "Vue"
source: "https://www.cloudpdf.com/docs/headless/vue/concepts/coordinates"
---

# Coordinates

Every position in EmbedPDF is in **page coordinates**: points (1/72 inch) from the top-left corner of
the page as it's shown, with y going down, like HTML. Annotations, text, search results, selections,
form fields and the places you scroll to all use them. A box is `{ x, y, width, height }` and a point
is `{ x, y }`:

```ts
annotation.rect;
// { x: 100, y: 80, width: 20, height: 20 }
```

Colors work the same everywhere too: hex strings such as `'#dc143c'`. Angles are degrees, clockwise.

## Pass values straight through

Because every part speaks the same coordinates, a value from one goes to another as it is. A comment
thread knows where its first comment is, and that's where the Stage should go:

**`thread-links.vue`**

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

const threads = useCommentThreads();
const stage = useStage();
</script>

<template>
  <button
    v-for="thread in threads"
    :key="annotationKey(thread.root.ref)"
    @click="stage.reveal(thread.page, { rect: thread.root.rect })"
  >
    {{ thread.root.contents }}, page {{ thread.pageLabel }}
  </button>
</template>
```

There's nothing to convert, whether the page is zoomed, rotated or scrolled. A search match says
where it is the same way, and the Stage goes there:

**`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 { SearchLayer, searchPlugin } from '@embedpdf/vue/search';
import { cloudEngine } from '@cloudpdf/engine';
import Hits from './Hits.vue';

import '../pass-through.css';

const engine = cloudEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin()];

const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };
</script>

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

**`pass-through.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --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 matches beside the pages; above them on a narrow screen */
.layout {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.hits {
  flex: 1 1 100%;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 6px;
  align-content: start;
  margin: 0;
  padding: 0;
  list-style: none;
}

.hit {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  font: inherit;
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 7px 10px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.hit:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

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

.where {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
}

.rect {
  font-size: 11.5px;
  color: var(--accent);
}

/* 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);
  flex: 1 1 100%;
}

@container (min-width: 620px) {
  .hits {
    flex: 0 0 180px;
    grid-template-columns: 1fr;
  }

  .stage {
    flex: 1 1 0;
    min-width: 0;
  }
}

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

**`Hits.vue`**

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

// A search hit says where it is in page coordinates; the Stage takes them as they are.
const search = useSearch();
const stage = useStage();
const hits = useSearchHits();
// The first eight that have a place on the page.
const shown = computed(() =>
  hits.value.slice(0, 8).flatMap((hit) => (hit.bounds ? [{ hit, bounds: hit.bounds }] : [])),
);

void search.search({ text: 'PDF' });
</script>

<template>
  <ol class="hits">
    <li v-for="{ hit, bounds } in shown" :key="`${hit.page.objectNumber}:${hit.start}`">
      <button
        type="button"
        class="hit"
        @click="stage.reveal(hit.page, { rect: bounds, anchor: { y: 0.35 } })"
      >
        <span class="where">page {{ hit.pageIndex + 1 }}</span>
        <code class="rect">x {{ Math.round(bounds.x) }}, y {{ Math.round(bounds.y) }}</code>
      </button>
    </li>
  </ol>
</template>
```

## Pages

A call that takes a page takes its `ref` or its index. A `ref` stays the same when pages move, so keep
refs, not indexes, in your own state. Everything that is on a page hands you its page's ref, such as
`annotation.ref.page` or a search hit's `hit.page`. A ref is an object, so compare two with
`pageRefsEqual(left, right)` from `/runtime`, not `===`.

```ts
stage.goToPage(4); // the fifth page, counting from 0
stage.goToPage(hit.page); // the page of a search hit
```

## Pixels, only when you draw

Pixels change with every zoom, rotation and scroll, so nothing in EmbedPDF stores them. When you draw
something of your own on a page, convert at the last moment with the page's `transform`. Which call
depends on where you draw:

- **On the page**, in the Stage's scoped slot or inside `<PageView>`, with the
  layers: that box turns with the page, so convert for zoom only, with `toPixels`. What you draw turns
  along.
- **Over the page**, in its [page chrome](../viewing/stage#labels-and-buttons-on-every-page): that
  box stays upright, so convert with `pageToView`, which includes the page's turn.

An [annotation renderer](../annotations/custom-rendering) converts nothing: it draws into a frame the
layer places and turns for it.

**`pin.vue`**

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

// Your own data: points in page coordinates, by the page's object number.
const pins = new Map<number, { x: number; y: number }[]>();
</script>

<template>
  <Stage>
    <template #page="{ page }">
      <RenderLayer />
      <!-- page coordinates → pixels on this page, at its zoom; the page turns them with it -->
      <div
        v-for="(point, index) in pins.get(page.ref.objectNumber) ?? []"
        :key="index"
        :style="{
          position: 'absolute',
          left: `${page.transform.toPixels(point).x}px`,
          top: `${page.transform.toPixels(point).y}px`,
        }"
      >
        📌
      </div>
    </template>
  </Stage>
</template>
```

| From              | To                | On the page                                      | Over the page                        |
| :---------------- | :---------------- | :----------------------------------------------- | :----------------------------------- |
| A point on a page | Pixels            | `page.transform.toPixels(point)`                 | `page.transform.pageToView(point)`   |
| A box on a page   | Pixels            | two corners through `toPixels`                   | `page.transform.pageToViewRect(box)` |
| Pixels            | A point on a page | `page.transform.fromPixels(point)`               | `page.transform.viewToPage(point)`   |
| A pointer event   | A point on a page | `page.toPagePoint(event.clientX, event.clientY)` | the same                             |

To place many things over a page at once, put them in a container with `transform:
page.transform.cssMatrix` and `transform-origin: 0 0`, and give them page coordinates: the browser
does the zoom and the turn.

**`App.vue`**

```vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput, PageContextValue } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { cloudEngine } from '@cloudpdf/engine';
import Controls from './Controls.vue';
import type { Pin } from './pin';

import '../pins.css';

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

const welcome: OpenInput = { kind: 'grant', grant: 'shr_gvpJcG1ULUMon19C0D1wODSY' };

// One pin to start with: an inch in from the first page's top-left corner.
const pins = ref<Pin[]>([{ pageIndex: 0, point: { x: 72, y: 72 } }]);
const last = computed(() => pins.value[pins.value.length - 1] ?? null);
// Where the pointer went down: a press that moved was a drag to scroll, not a click.
let pressed = { x: 0, y: 0 };

function press(event: PointerEvent) {
  pressed = { x: event.clientX, y: event.clientY };
}

function drop(page: PageContextValue, event: MouseEvent) {
  const moved = Math.hypot(event.clientX - pressed.x, event.clientY - pressed.y);
  if (moved > 4) return;
  // A pointer event, to a point on the page.
  const point = page.toPagePoint(event.clientX, event.clientY);
  pins.value = [...pins.value, { pageIndex: page.pageIndex, point }];
}
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: welcome }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <Controls :last="last" @clear="pins = []" />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
        <!--
          What's drawn here sits over the page and never turns with it, so it
          converts with pageToView, which includes the turn.
        -->
        <template #page-chrome="{ page }">
          <div class="surface" @pointerdown="press" @click="drop(page, $event)">
            <!-- Many things at once: the browser maps page points with one matrix. -->
            <div
              v-if="page.pageIndex === 0"
              class="page-space"
              :style="{ transform: page.transform.cssMatrix }"
            >
              <div class="inch">1 inch</div>
            </div>
            <!-- A point on the page, to pixels on it: at the last moment. -->
            <div
              v-for="(pin, index) in pins.filter((pin) => pin.pageIndex === page.pageIndex)"
              :key="index"
              class="pin"
              :style="{
                left: `${page.transform.pageToView(pin.point).x}px`,
                top: `${page.transform.pageToView(pin.point).y}px`,
              }"
            />
          </div>
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>
```

**`pins.css`**

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

/* Zoom and turn the view: the pins stay on their spot of the page */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  min-width: 38px;
  font: inherit;
  font-size: 13.5px;
  font-weight: 550;
  line-height: 1.1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 11px;
  cursor: pointer;
  white-space: nowrap;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:active {
  transform: translateY(1px);
}

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

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  transform: none;
}

.badge {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--muted);
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 11px;
  white-space: nowrap;
  margin-left: auto;
}

.badge strong {
  color: var(--ink);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* Over each page, catching clicks */
.surface {
  position: absolute;
  inset: 0;
  cursor: crosshair;
}

.pin {
  position: absolute;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  border: 2px solid #fff;
  border-radius: 50%;
  background: var(--mark);
  box-shadow: 0 1px 4px rgb(7 32 76 / 0.45);
  pointer-events: none;
}

/* A layer in page points: 1 unit here is 1 point on the page */
.page-space {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  pointer-events: none;
}

.inch {
  position: absolute;
  left: 72px;
  top: 72px;
  width: 72px;
  height: 72px;
  box-sizing: border-box;
  display: flex;
  align-items: flex-end;
  justify-content: flex-end;
  padding: 3px;
  font-size: 8px;
  font-weight: 700;
  color: var(--mark);
  border: 1px dashed var(--mark);
  background: rgb(8 118 253 / 0.06);
}

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

**`Controls.vue`**

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

defineProps<{ last: Pin | null }>();
const emit = defineEmits<{ clear: [] }>();

const stage = useStage();
</script>

<template>
  <div class="toolbar">
    <button type="button" class="button" @click="stage.zoomOut()">Zoom out</button>
    <button type="button" class="button" @click="stage.zoomIn()">Zoom in</button>
    <button type="button" class="button" @click="stage.rotateViewBy(90)">Rotate ⟳</button>
    <button type="button" class="button" @click="emit('clear')">Clear pins</button>
    <output class="badge">
      <template v-if="last">
        page <strong>{{ last.pageIndex + 1 }}</strong> · x
        <strong>{{ last.point.x.toFixed(1) }}</strong> · y
        <strong>{{ last.point.y.toFixed(1) }}</strong>
      </template>
      <template v-else>Click a page to drop a pin</template>
    </output>
  </div>
</template>
```

**`pin.ts`**

```typescript
// A pin is a page and a point on it, in points from the page's top-left. No pixels.
export interface Pin {
  pageIndex: number;
  point: { x: number; y: number };
}
```

> **Try it:** click a page to drop a pin, then zoom and turn the view. Each pin keeps its numbers
> and its spot on the page; only its pixels change.

## Tools that use the PDF file's numbers

The PDF file stores positions in its own numbers, with y going up, and a page can sit anywhere in
them. You only meet those numbers next to a tool that reads them, such as iText, PDFBox or Acrobat
JavaScript. `page.pdfCropBox` says where the page sits in them, and `pdfRectOf()`, `pdfPointOf()`,
`pageBoxOf()` and `pagePointOf()` convert. See [Coordinates](https://www.cloudpdf.com/docs/engine/concepts/coordinates) in the
engine docs.

## Cheat sheet

| You're…                                            | Use              | Shape                                                                 |
| :------------------------------------------------- | :--------------- | :-------------------------------------------------------------------- |
| Working with annotations, text, search or `reveal` | page coordinates | `{ x, y, width, height }` and `{ x, y }`                              |
| Drawing on a page right now                        | `page.transform` | pixels: convert, never store (`toPixels` on it, `pageToView` over it) |
| Talking to a tool that reads the PDF's own numbers | `pdfRectOf()`    | `{ left, bottom, right, top }`                                        |

**Bringing your own coordinate system?** Page coordinates are a plain frame in points with y going
down, so one transform maps them into yours.
