---
title: "Links — React"
description: "Make the links in a PDF work: go to a page, open a website, and change how link areas look."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/viewing/links"
---

# Links

Most PDFs have links: a table of contents that jumps to a chapter, a footnote to a website. Register
`linkPlugin()` and put `<LinkLayer>` in each page, and they work:

**`link-layer.tsx`**

```tsx
import { interactionPlugin } from '@embedpdf/react/interaction';
import { LinkLayer, linkPlugin } from '@embedpdf/react/link';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { Viewer } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { engine } from './pdf';

const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), linkPlugin()];

export function Reader() {
  return (
    <Viewer engine={engine} plugins={plugins}>
      <Stage>
        {() => (
          <>
            <RenderLayer />
            <LinkLayer />
          </>
        )}
      </Stage>
    </Viewer>
  );
}
```

**`basic.tsx`**

```tsx
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import { annotationPlugin, useAnnotation } from '@embedpdf/react/annotation';
import { LinkLayer, linkPlugin } from '@embedpdf/react/link';
import { localEngine } from '@embedpdf/engine';

import './basic.css';

const engine = localEngine();
// The annotation plugin is only here to make the links below.
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  annotationPlugin(),
  linkPlugin(),
];

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()) };
};

// Where the two links go on the first page, in page coordinates.
const TO_PAGE_3 = { x: 72, y: 24, width: 160, height: 28 };
const TO_WEBSITE = { x: 250, y: 24, width: 190, height: 28 };

// Made on load, since the document has none: a label the page shows, and a link over it.
function AddLinks() {
  const annotation = useAnnotation();
  const pages = usePageList();
  const added = useRef(false);

  useEffect(() => {
    const [first, , third] = pages;
    if (!first || !third || added.current) return;
    added.current = true;
    const label = (text: string, box: typeof TO_PAGE_3) =>
      annotation.create(first.ref, {
        subtype: 'free-text',
        box,
        contents: text,
        fontSize: 13,
        fontColor: '#054fb3',
        interiorColor: '#e8f1ff',
        color: '#7db6ff',
        strokeWidth: 1,
      });
    void (async () => {
      await label('Go to page 3 →', TO_PAGE_3);
      await label('Open embedpdf.com ↗', TO_WEBSITE);
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_PAGE_3,
        target: { kind: 'goto', destination: { kind: 'fit', page: third.ref } },
      });
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_WEBSITE,
        target: { kind: 'uri', uri: 'https://www.embedpdf.com' },
      });
    })();
  }, [annotation, pages]);

  return null;
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddLinks />
        <p className="hint">
          Two links at the top of the first page. Click one, or press Tab to reach it and Enter to
          follow it.
        </p>
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <LinkLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}
```

**`basic.css`**

```css
/* Colors */
:root {
  --muted: #5a6b92;
  --line: #e6eaf2;
  --canvas: #f7f9fc;
  --shadow: rgb(7 32 76 / 0.04);
}

/* What to try, above the pages */
.hint {
  margin: 0 0 12px;
  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);
}
```

- **A link to a page** goes there, to the exact spot and zoom the link names.
- **A link to a website** opens in a new tab, after a safety check (see [Good to know](#good-to-know)).
- **A link with a PDF action**, such as a button that resets a form, runs the action when the
  [actions plugin](../documents/actions) is registered.

The pointer turns into a hand over a link, and a link can be reached with the Tab key and followed
with Enter.

## Change how links look

By default a link area is invisible, as in other PDF apps. `<LinkLayer renderLink>` lets you draw it
yourself, for example with an underline on hover:

**`custom-links.tsx`**

```tsx
import { LinkLayer } from '@embedpdf/react/link';

export const Links = () => (
  <LinkLayer
    renderLink={({ link, native }) => (
      <span className="pdf-link" title={link.target.kind === 'uri' ? link.target.uri : undefined}>
        {native}
      </span>
    )}
  />
);
```

**`custom.tsx`**

```tsx
import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import { annotationPlugin, useAnnotation } from '@embedpdf/react/annotation';
import { LinkLayer, linkPlugin } from '@embedpdf/react/link';
import { localEngine } from '@embedpdf/engine';

import './custom.css';

const engine = localEngine();
// The annotation plugin is only here to make the links below.
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  annotationPlugin(),
  linkPlugin(),
];

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()) };
};

// Where the two links go on the first page, in page coordinates.
const TO_PAGE_3 = { x: 72, y: 24, width: 160, height: 28 };
const TO_WEBSITE = { x: 250, y: 24, width: 190, height: 28 };

// Made on load, since the document has none: a label the page shows, and a link over it.
function AddLinks() {
  const annotation = useAnnotation();
  const pages = usePageList();
  const added = useRef(false);

  useEffect(() => {
    const [first, , third] = pages;
    if (!first || !third || added.current) return;
    added.current = true;
    const label = (text: string, box: typeof TO_PAGE_3) =>
      annotation.create(first.ref, {
        subtype: 'free-text',
        box,
        contents: text,
        fontSize: 13,
        fontColor: '#054fb3',
        interiorColor: '#e8f1ff',
        color: '#7db6ff',
        strokeWidth: 1,
      });
    void (async () => {
      await label('Go to page 3 →', TO_PAGE_3);
      await label('Open embedpdf.com ↗', TO_WEBSITE);
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_PAGE_3,
        target: { kind: 'goto', destination: { kind: 'fit', page: third.ref } },
      });
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_WEBSITE,
        target: { kind: 'uri', uri: 'https://www.embedpdf.com' },
      });
    })();
  }, [annotation, pages]);

  return null;
}

export default function App() {
  const [shown, setShown] = useState(true);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddLinks />
        <div className="toolbar">
          <div className="segmented" role="group" aria-label="Link areas">
            <button type="button" aria-pressed={shown} onClick={() => setShown(true)}>
              Show link areas
            </button>
            <button type="button" aria-pressed={!shown} onClick={() => setShown(false)}>
              As the PDF has them
            </button>
          </div>
        </div>
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <LinkLayer
                renderLink={
                  ({ link, native }) =>
                    shown ? (
                      // Wrapping `native` keeps what a click does.
                      <span className="pdf-link" data-kind={link.target.kind}>
                        {native}
                      </span>
                    ) : undefined // the layer's own, invisible area
                }
              />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}
```

**`custom.css`**

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

/* Show or hide the link areas */
.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;
}

/* Your look for a link: a tint, and an underline that grows on hover */
.pdf-link a {
  border-radius: 4px;
  background: rgb(8 118 253 / 0.08);
  box-shadow: inset 0 -2px 0 rgb(8 118 253 / 0.55);
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}

.pdf-link a:hover {
  background: rgb(8 118 253 / 0.16);
  box-shadow: inset 0 -3px 0 var(--mark);
}

/* A link to a website gets a different color */
.pdf-link[data-kind='uri'] a {
  background: rgb(22 163 74 / 0.08);
  box-shadow: inset 0 -2px 0 rgb(22 163 74 / 0.6);
}

.pdf-link[data-kind='uri'] a:hover {
  background: rgb(22 163 74 / 0.16);
}

.pdf-link a: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);
}
```

`native` is the layer's own clickable area: wrap it to keep what a click does.
Return `undefined` for a link you don't want to change.

## Read the links on a page

```ts
link.listLinks(page.ref); // every link on the page
link.getLinkAt(page.ref, { x: 120, y: 80 }); // the link at a point, or null
```

Each link has an `id`, its `bounds` on the page, and a `target`: where it goes.

| `target.kind`               | Goes to                                                                           |
| :-------------------------- | :-------------------------------------------------------------------------------- |
| `'goto'`                    | A page of this document: `target.destination`                                     |
| `'uri'`                     | A website or an email address: `target.uri`                                       |
| `'named'`                   | A viewer action, such as the next page                                            |
| `'javascript'`              | Runs the link's script, when the [actions plugin](../documents/actions) allows it |
| `'goto-remote'`, `'launch'` | Nowhere: see [Which links never run](#which-links-never-run)                      |

## Follow a link from code

`activate()` does what a click does:

```ts
const { outcome } = link.activate(someLink);
```

It returns what happened right away, so a website can still open in a new tab: `'revealed'` (the
view went to a page), `'uri'` (a website opened), or `'dispatched'` (the actions plugin runs the
link's action). A link it won't follow, such as one to another file, is `'reported'`.

**`list.tsx`**

```tsx
import { useCallback, useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { interactionPlugin } from '@embedpdf/react/interaction';
import { annotationPlugin, useAnnotation } from '@embedpdf/react/annotation';
import { LinkLayer, linkPlugin, useLink, useLinkEvent } from '@embedpdf/react/link';
import { localEngine } from '@embedpdf/engine';

import './list.css';

const engine = localEngine();
// The annotation plugin is only here to make the links below.
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  annotationPlugin(),
  linkPlugin(),
];

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()) };
};

// Where the links go on the first page, in page coordinates.
const TO_PAGE_3 = { x: 72, y: 24, width: 160, height: 28 };
const TO_WEBSITE = { x: 250, y: 24, width: 190, height: 28 };
const TO_SCRIPT = { x: 458, y: 24, width: 82, height: 28 };

// Made on load, since the document has none: a label the page shows, and a link over it.
// The third one is a `javascript:` address, which a link never opens.
function AddLinks({ onAdded }: { onAdded: () => void }) {
  const annotation = useAnnotation();
  const pages = usePageList();
  const added = useRef(false);

  useEffect(() => {
    const [first, , third] = pages;
    if (!first || !third || added.current) return;
    added.current = true;
    const label = (text: string, box: typeof TO_PAGE_3) =>
      annotation.create(first.ref, {
        subtype: 'free-text',
        box,
        contents: text,
        fontSize: 13,
        fontColor: '#054fb3',
        interiorColor: '#e8f1ff',
        color: '#7db6ff',
        strokeWidth: 1,
      });
    void (async () => {
      await label('Go to page 3 →', TO_PAGE_3);
      await label('Open embedpdf.com ↗', TO_WEBSITE);
      await label('A script', TO_SCRIPT);
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_PAGE_3,
        target: { kind: 'goto', destination: { kind: 'fit', page: third.ref } },
      });
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_WEBSITE,
        target: { kind: 'uri', uri: 'https://www.embedpdf.com' },
      });
      await annotation.create(first.ref, {
        subtype: 'link',
        rect: TO_SCRIPT,
        target: { kind: 'uri', uri: 'javascript:alert(1)' },
      });
      onAdded();
    })();
  }, [annotation, pages, onAdded]);

  return null;
}

// The first page's links, read from the plugin, each with a button that follows it.
function LinkList({ ready }: { ready: boolean }) {
  const link = useLink();
  const [last, setLast] = useState<string | null>(null);

  // Every way of following a link ends up here: a click, a key, or code.
  useLinkEvent(
    (link) => link.onActivated,
    ({ target, activation }) => setLast(`${target.kind} → ${activation.outcome}`),
  );

  const links = ready ? link.listLinks(0) : [];

  return (
    <aside className="panel">
      <h3 className="heading">Links on page 1</h3>
      <ul className="links">
        {links.map((item) => (
          <li key={item.id} className="link">
            <span className="kind">{item.target.kind}</span>
            <span className="label">{link.getLabel(item)}</span>
            <span className="where">
              at {Math.round(item.bounds.x)}, {Math.round(item.bounds.y)}
            </span>
            <button type="button" className="button" onClick={() => link.activate(item)}>
              Follow
            </button>
          </li>
        ))}
      </ul>
      <p className="last">
        <code>onActivated</code> {last ?? 'not yet'}
      </p>
    </aside>
  );
}

export default function App() {
  // The list reads the links once they're made.
  const [ready, setReady] = useState(false);
  const onAdded = useCallback(() => setReady(true), []);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <AddLinks onAdded={onAdded} />
        <div className="layout">
          <LinkList ready={ready} />
          <Stage className="stage">
            {() => (
              <>
                <RenderLayer />
                <LinkLayer />
              </>
            )}
          </Stage>
        </div>
      </DocumentGate>
    </Viewer>
  );
}
```

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

.panel {
  flex: 1 1 100%;
  min-width: 0;
}

.heading {
  margin: 0 0 8px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.links {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* One link: what it is, where it goes, where it sits, and a button to follow it */
.link {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 2px 8px;
  align-items: center;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 8px 10px;
  box-shadow: 0 1px 2px var(--shadow);
}

.kind {
  font-family: ui-monospace, monospace;
  font-size: 11.5px;
  color: var(--accent);
}

.label {
  grid-column: 1;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.where {
  grid-column: 1;
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.link .button {
  grid-column: 2;
  grid-row: 1 / span 3;
}

.last {
  margin: 10px 0 0;
  font-size: 12.5px;
  color: var(--ink);
}

.last code {
  margin-right: 6px;
  font-weight: 600;
  color: var(--accent);
}

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

/* 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) {
  .panel {
    flex: 0 0 240px;
  }

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

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

## Methods

Everything `useLink()` gives you.

| Method                              | What it does                                                                                                                          |
| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
| `listLinks(page)`                   | Every link on a page, once the page's links are read. [Read the links on a page](#read-the-links-on-a-page)                           |
| `getLink(page, id)`                 | One link by its `id`, or `null`                                                                                                       |
| `getLinkAt(page, point)`            | The link at a point on the page, the smallest when links overlap, or `null`                                                           |
| `listAllLinks()`                    | Every link in the document, reading each page's links as needed                                                                       |
| `ensureLoaded(page)`                | Reads a page's links if they aren't read yet. `<LinkLayer>` does this for every page it shows.                                        |
| `isLoaded(page)`, `getStatus(page)` | Whether a page's links are read, and in detail: `'idle'`, `'loading'`, `'ready'`, `'error'` or `'forbidden'`                          |
| `activate(link)`                    | Follows a link, or a link's `target`, as a click does, and returns what happened. [Follow a link from code](#follow-a-link-from-code) |
| `activateAt(page, point)`           | Follows the link at a point on the page. Returns `null` when there's no link there.                                                   |
| `resolve(target)`                   | What following a target would do, without doing it: `{ kind }` is `'destination'`, `'uri'`, `'named'` or `'reported'`                 |
| `getLabel(link)`                    | A short label for a tooltip: the web address, or where the link goes                                                                  |

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

## Events

**`link-analytics.tsx`**

```tsx
import { LinkLayer, useLinkEvent } from '@embedpdf/react/link';
import { RenderLayer } from '@embedpdf/react/render';
import { Stage } from '@embedpdf/react/stage';
import { analytics } from './analytics';

export function Reader() {
  useLinkEvent(
    (link) => link.onActivated,
    ({ target }) => analytics.track('link_followed', { kind: target.kind }),
  );

  return (
    <Stage>
      {() => (
        <>
          <RenderLayer />
          <LinkLayer />
        </>
      )}
    </Stage>
  );
}
```

| Event         | Fires when                                             | Carries                |
| :------------ | :----------------------------------------------------- | :--------------------- |
| `onActivated` | Someone followed a link, by click, key or code         | `target`, `activation` |
| `onLoaded`    | A page's links were read, or read again after a change | `page`                 |

## Good to know

### Which links never run

A website link opens only with `http`, `https`, `mailto` or `tel`. An address without one opens on
`https` when it starts with `//` or `www.`; any other, such as `/help` or `page2.html`, is relative
to the document and opens nothing, so a link can never open a page of your own app. Links that
would start a program or open another file on the user's computer are never followed, and a link's
JavaScript runs only when the [actions plugin](../documents/actions) has JavaScript turned on.

### Links while editing annotations

Links work while the `pointer` or `pan` tool is active. With the annotation plugin's `link` tool,
the layer steps aside and links become annotations you can move, resize and retarget; see
[Annotations](../annotations).

### Making links

Links are annotations: create them with the annotation plugin, or with the engine; see
[Links](https://www.embedpdf.com/docs/engine/annotations/links).
