---
title: "Coordinates — React"
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: "React"
source: "https://www.cloudpdf.com/docs/headless/react/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.tsx`**

```tsx
import { annotationKey, useCommentThreads } from '@embedpdf/react/annotation';
import { useStage } from '@embedpdf/react/stage';

export function ThreadLinks() {
  const threads = useCommentThreads();
  const stage = useStage();

  return threads.map((thread) => (
    <button
      key={annotationKey(thread.root.ref)}
      onClick={() => stage.reveal(thread.page, { rect: thread.root.rect })}
    >
      {thread.root.contents}, page {thread.pageLabel}
    </button>
  ));
}
```

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:

**`pass-through.tsx`**

```tsx
import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { cloudEngine } from '@cloudpdf/engine';

import './pass-through.css';

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

const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };

// A search hit says where it is in page coordinates; the Stage takes them as they are.
function Hits() {
  const search = useSearch();
  const stage = useStage();
  const hits = useSearchHits();

  useEffect(() => {
    void search.search({ text: 'PDF' });
  }, [search]);

  return (
    <ol className="hits">
      {hits.slice(0, 8).map((hit) =>
        hit.bounds ? (
          <li key={`${hit.page.objectNumber}:${hit.start}`}>
            <button
              type="button"
              className="hit"
              onClick={() => stage.reveal(hit.page, { rect: hit.bounds, anchor: { y: 0.35 } })}
            >
              <span className="where">page {hit.pageIndex + 1}</span>
              <code className="rect">
                x {Math.round(hit.bounds.x)}, y {Math.round(hit.bounds.y)}
              </code>
            </button>
          </li>
        ) : null,
      )}
    </ol>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="layout">
          <Hits />
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <SearchLayer />
              </>
            )}
          </Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}
```

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

## 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 render function 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.tsx`**

```tsx
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';

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

export const Pages = () => (
  <Stage>
    {(page) => (
      <>
        <RenderLayer />
        {(pins.get(page.ref.objectNumber) ?? []).map((point, index) => {
          // page coordinates → pixels on this page, at its zoom; the page turns them with it
          const { x, y } = page.transform.toPixels(point);
          return (
            <div key={index} style={{ position: 'absolute', left: x, top: y }}>
              📌
            </div>
          );
        })}
      </>
    )}
  </Stage>
);
```

| 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.

**`pins.tsx`**

```tsx
import { useRef, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { cloudEngine } from '@cloudpdf/engine';

import './pins.css';

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

const ebook: OpenInput = { kind: 'share', shareToken: 'shr_WGj1goAtlNN_fQ5OswPrbJQM' };

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

function Controls({ last, onClear }: { last: Pin | null; onClear: () => void }) {
  const stage = useStage();

  return (
    <div className="toolbar">
      <button type="button" className="button" onClick={() => stage.zoomOut()}>
        Zoom out
      </button>
      <button type="button" className="button" onClick={() => stage.zoomIn()}>
        Zoom in
      </button>
      <button type="button" className="button" onClick={() => stage.rotateViewBy(90)}>
        Rotate ⟳
      </button>
      <button type="button" className="button" onClick={onClear}>
        Clear pins
      </button>
      <output className="badge">
        {last ? (
          <>
            page <strong>{last.pageIndex + 1}</strong> · x{' '}
            <strong>{last.point.x.toFixed(1)}</strong> · y{' '}
            <strong>{last.point.y.toFixed(1)}</strong>
          </>
        ) : (
          'Click a page to drop a pin'
        )}
      </output>
    </div>
  );
}

export default function App() {
  // One pin to start with: an inch in from the first page's top-left corner.
  const [pins, setPins] = useState<Pin[]>([{ pageIndex: 0, point: { x: 72, y: 72 } }]);
  // Where the pointer went down: a press that moved was a drag to scroll, not a click.
  const pressed = useRef({ x: 0, y: 0 });

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Controls last={pins[pins.length - 1] ?? null} onClear={() => setPins([])} />
        <Stage
          className="stage"
          // What's drawn here sits over the page and never turns with it, so it
          // converts with pageToView, which includes the turn.
          pageChrome={(page) => (
            <div
              className="surface"
              onPointerDown={(event) => (pressed.current = { x: event.clientX, y: event.clientY })}
              onClick={(event) => {
                const moved = Math.hypot(
                  event.clientX - pressed.current.x,
                  event.clientY - pressed.current.y,
                );
                if (moved > 4) return;
                // A pointer event, to a point on the page.
                const point = page.toPagePoint(event.clientX, event.clientY);
                setPins((current) => [...current, { pageIndex: page.pageIndex, point }]);
              }}
            >
              {page.pageIndex === 0 ? (
                // Many things at once: the browser maps page points with one matrix.
                <div className="page-space" style={{ transform: page.transform.cssMatrix }}>
                  <div className="inch">1 inch</div>
                </div>
              ) : null}
              {pins
                .filter((pin) => pin.pageIndex === page.pageIndex)
                .map((pin, index) => {
                  // A point on the page, to pixels on it: at the last moment.
                  const at = page.transform.pageToView(pin.point);
                  return <div key={index} className="pin" style={{ left: at.x, top: at.y }} />;
                })}
            </div>
          )}
        >
          {() => <RenderLayer />}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}
```

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

> **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.
