---
title: "How it works — Angular"
description: "How the headless packages fit together: the viewer, plugins, what a plugin gives your components, layers, and which document a component talks to."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/concepts/how-it-works"
---

# How it works

A headless viewer is a few parts that always work the same way. Learn them once here, and every
plugin page reads the same: the same kind of service, the same kinds of settings and events, the same
checks.

## The viewer and its documents

`provideEmbedPdf()` sets up the engine, the plugins and the open documents. Put it in a component's
`providers`, and that component and everything in its template can use them:

**`viewer.ts`**

```typescript
import { Component } from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { engine, source } from './pdf';
import { Toolbar } from './toolbar';

@Component({
  selector: 'app-viewer',
  imports: [Toolbar, EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  providers: [
    provideEmbedPdf({ engine, initialDocuments: [{ source }] }, withStage(), withRender()),
  ],
  template: `
    <app-toolbar />
    <epdf-stage *epdfDocumentGate="let document; fallback: opening">
      <ng-template epdfPage>
        <epdf-render-layer />
      </ng-template>
    </epdf-stage>

    <ng-template #opening><p>Opening…</p></ng-template>
  `,
})
export class ViewerPage {}
```

It works the same in your app config or in a route's `providers`. `engine` is a function that
creates the engine: it runs only in the browser, and it may return a promise, so a dynamic
`import()` keeps the engine out of the page's first download. A document that comes from an input
or a route parameter goes on the Stage instead, `<epdf-stage [document]="source()">`: when the
source changes, the new document opens and the old one closes.

A viewer can have several documents open, like tabs, and one of them is active. A component talks to the
active document unless it's inside an element with `[epdfDocumentScope]`: then every service it
injects acts on that document. That's how a tab strip or a side-by-side view gives each part its own
document:

**`document-scope.ts`**

```typescript
import { Component, input } from '@angular/core';
import { EpdfDocumentScope } from '@embedpdf/angular/runtime';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';

@Component({
  selector: 'app-document-pane',
  imports: [EpdfDocumentScope, EpdfStage, EpdfPageTemplate, EpdfRenderLayer],
  template: `
    <div [epdfDocumentScope]="documentId()">
      <epdf-stage>
        <ng-template epdfPage>
          <epdf-render-layer />
        </ng-template>
      </epdf-stage>
    </div>
  `,
})
export class DocumentPane {
  readonly documentId = input.required<string>();
}
```

`*epdfDocumentGate` renders the element it's on only once the document is open, so the components inside can
count on it. See [Opening documents](../documents/opening).

## Plugins

A plugin adds one feature. You register it in `provideEmbedPdf()`, with its settings:

**`plugins.ts`**

```typescript
import type { ApplicationConfig } from '@angular/core';
import { provideEmbedPdf } from '@embedpdf/angular/runtime';
import { withRender } from '@embedpdf/angular/render';
import { withSearch } from '@embedpdf/angular/search';
import { withStage } from '@embedpdf/angular/stage';
import { engine } from './pdf';

export const appConfig: ApplicationConfig = {
  providers: [
    provideEmbedPdf({ engine }, withStage({ layout: 'horizontal' }), withRender(), withSearch()),
  ],
};
```

Most plugins keep their state per document: each open document has its own search, its own
selection, its own zoom. A few belong to the whole viewer, such as translations, commands and stamp
libraries.

## What a plugin gives you

Every plugin gives your components one service, `inject(EpdfSearch)` for search, with four parts:

| Part     | Gives you                                      | For search                                    |
| :------- | :--------------------------------------------- | :-------------------------------------------- |
| Methods  | the plugin's API: everything it can do         | `search.search({ text })`, `search.nextHit()` |
| Signals  | the data to show, such as the number of hits   | `search.hitCount()`                           |
| Streams  | a call to your function when something happens | `search.completed$`                           |
| Settings | the plugin's current settings, as a signal     | `search.settings()`                           |

Search is the example; every plugin follows the pattern: `inject(EpdfAnnotation)`,
`inject(EpdfSelection)`, and so on. A plugin without settings has no `settings()`, and one without
events has no streams. The Stage follows it too, with `inject(EpdfStageRef)`: inside a Stage it's
that Stage, and anywhere else the main Stage of the document in scope. From around a Stage, a
template reference, `<epdf-stage #stage="epdfStage">`, or `viewChild(EpdfStage)` reaches it.

**`search-box.ts`**

```typescript
import { Component, inject } from '@angular/core';
import { EpdfSearch } from '@embedpdf/angular/search';

@Component({
  selector: 'app-search-box',
  template: `
    <input #query (input)="search.search({ text: query.value })" />
    <span>{{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}</span>
    <button (click)="search.nextHit()">Next</button>
  `,
})
export class SearchBox {
  protected readonly search = inject(EpdfSearch);
}
```

- **Do things with its methods.** The service is the same object for as long as your component
  lives, so call it from event bindings, `effect()` or your own methods.
- **Show things with its signals.** Read them in your template or in a `computed()`. A signal
  updates only what reads it, so a count doesn't redraw a list.
- **React to things with its streams.** They're RxJS observables, and `takeUntilDestroyed()` stops
  listening when your component goes away:

**`match-announcer.ts`**

```typescript
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfSearch } from '@embedpdf/angular/search';

@Component({
  selector: 'app-match-announcer',
  template: '',
})
export class MatchAnnouncer {
  private readonly announcer = inject(LiveAnnouncer);
  private readonly search = inject(EpdfSearch);

  constructor() {
    this.search.completed$
      .pipe(takeUntilDestroyed())
      .subscribe(({ hitCount }) => this.announcer.announce(`${hitCount} matches`));
  }
}
```

All four together: the API searches, the state shows the count, the event announces each finished
search, and the settings change the highlight color.

**`hooks.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';

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

const colors = ['#ffd500', '#7dd3fc', '#86efac'];

@Component({
  selector: 'demo-search-box',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toolbar">
      <input
        #field
        class="field"
        type="search"
        aria-label="Search"
        [value]="text()"
        (input)="text.set(field.value)"
      />
      <!-- The data to show: signals, which update only what reads them. -->
      <output class="readout">
        @if (search.hitCount() > 0) {
          {{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}
        } @else {
          No matches
        }
      </output>
      <!-- The API: what search can do. -->
      <button type="button" class="button" (click)="search.nextHit()">Next</button>
      <!-- The settings: a signal too, so the pressed swatch follows the highlight color. -->
      @for (swatch of colors; track swatch) {
        <button
          type="button"
          class="swatch"
          [attr.aria-label]="'Highlight in ' + swatch"
          [attr.aria-pressed]="search.settings().highlight.color === swatch"
          [style.background]="swatch"
          (click)="search.updateSettings({ highlight: { color: swatch } })"
        ></button>
      }
      <button type="button" class="button" (click)="search.resetSettings()">Reset</button>
    </div>
    <p class="announcement" aria-live="polite">{{ announcement() }}</p>
  `,
})
export class SearchBox {
  // One service for everything: the API, the data, the events and the settings.
  protected readonly search = inject(EpdfSearch);
  protected readonly colors = colors;
  protected readonly text = signal('PDF');
  protected readonly announcement = signal('');

  constructor() {
    // A call to your function when something happens: an RxJS stream.
    this.search.completed$
      .pipe(takeUntilDestroyed())
      .subscribe(({ hitCount }) =>
        this.announcement.set(`The search finished with ${hitCount} matches.`),
      );

    effect(() => {
      const text = this.text();
      void this.search.search({ text });
    });
  }
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfSearchLayer,
    SearchBox,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withSearch(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './hooks.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <demo-search-box />
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-search-layer />
        </ng-template>
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Opening…</p></ng-template>
  `,
})
export class App {}
```

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

/* The search box: what each hook gives it */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.field {
  appearance: none;
  flex: 1 1 140px;
  min-width: 0;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 12px;
}

.readout {
  min-width: 70px;
  text-align: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

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

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

/* A highlight color to pick: the setting, changed while the app runs */
.swatch {
  width: 28px;
  height: 28px;
  padding: 0;
  border: 2px solid var(--surface);
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--line);
  cursor: pointer;
}

.swatch[aria-pressed='true'] {
  box-shadow: 0 0 0 2px var(--ink);
}

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

/* What the event hook heard last */
.announcement {
  min-height: 18px;
  margin: 0 0 14px;
  font-size: 12px;
  color: var(--muted);
}

/* The pages */
.stage {
  height: 400px;
  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 few signals read one specific thing, and the ones that need an argument are methods that return a
signal: `annotation.watch(filter)`, `document.pages()` or `shell.surface(id)`.

## Layers and overlays

What you draw goes in one of two places.

**Layers** draw inside each page. The Stage stamps out its `<ng-template epdfPage>` once per visible page, and each layer
draws for that page. Later layers sit on top:

**`layers.ts`**

```typescript
import { Component } from '@angular/core';
import { EpdfAnnotationLayer } from '@embedpdf/angular/annotation';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfSearchLayer } from '@embedpdf/angular/search';
import { EpdfSelectionLayer } from '@embedpdf/angular/selection';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';

@Component({
  selector: 'app-pages',
  imports: [
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfSearchLayer,
    EpdfSelectionLayer,
    EpdfAnnotationLayer,
  ],
  template: `
    <epdf-stage>
      <ng-template epdfPage>
        <epdf-render-layer />
        <epdf-search-layer />
        <epdf-selection-layer />
        <epdf-annotation-layer />
      </ng-template>
    </epdf-stage>
  `,
})
export class Pages {}
```

A layer zooms, scrolls and rotates with its page, and you never convert a position: everything a
plugin gives you is in page coordinates, and the layer places it. A page's layers also keep out of
each other's way: while an annotation layer or a form layer is on the page, the render layer leaves
what it draws out of the page picture, so nothing is drawn twice.

**Overlays** float above the pages, attached to something on them: a menu next to the selected
text, a card next to an annotation. They go inside `<epdf-stage>`, next to the page template, which keeps them attached
while the user scrolls and zooms. See [Menus over the page](../ui/anchored).

**`layers.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  effect,
  inject,
  signal,
  ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { withInteraction } from '@embedpdf/angular/interaction';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import {
  EpdfSelection,
  EpdfSelectionLayer,
  EpdfSelectionMenu,
  withSelection,
} from '@embedpdf/angular/selection';
import { localEngine } from '@embedpdf/engine';

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

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfSearchLayer,
    EpdfSelectionLayer,
    EpdfSelectionMenu,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withInteraction(),
      withSelection(),
      withSearch(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './layers.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <label class="option">
          <input
            #search
            type="checkbox"
            [checked]="showSearch()"
            (change)="showSearch.set(search.checked)"
          />
          Search matches
        </label>
        <label class="option">
          <input
            #selection
            type="checkbox"
            [checked]="showSelection()"
            (change)="showSelection.set(selection.checked)"
          />
          Text selection
        </label>
      </div>
      <!-- Layers draw inside each page, later ones on top; the menu floats above them. -->
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          @if (showSearch()) {
            <epdf-search-layer />
          }
          @if (showSelection()) {
            <epdf-selection-layer />
          }
        </ng-template>
        @if (showSelection()) {
          <epdf-selection-menu>
            <div class="menu">
              <button type="button" (click)="clearSelection()">Clear the selection</button>
            </div>
          </epdf-selection-menu>
        }
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Opening…</p></ng-template>
  `,
})
export class App {
  protected readonly showSearch = signal(true);
  protected readonly showSelection = signal(true);
  private readonly search = inject(EpdfSearch);
  private readonly selection = inject(EpdfSelection);
  private readonly document = inject(EpdfDocument);

  constructor() {
    // Something on every layer on load: the matches of a search, and the title selected.
    effect(() => {
      const cover = this.document.pages()[0]?.ref;
      if (!cover) return;
      void this.search.search({ text: 'PDF' });
      this.selection.select({ page: cover, start: 10, count: 52 });
    });
  }

  protected clearSelection() {
    this.selection.clear();
  }
}
```

**`layers.css`**

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

/* Which layers draw on the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.option {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 7px 11px;
  cursor: pointer;
  user-select: none;
  box-shadow: 0 1px 2px var(--shadow);
}

.option:has(input:checked) {
  background: var(--tint);
  border-color: var(--focus);
}

.option input {
  margin: 0;
  accent-color: var(--accent);
}

/* The overlay: a menu attached to the selected text */
.menu {
  display: flex;
  gap: 4px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 8px 24px -8px rgb(7 32 76 / 0.3);
}

.menu button {
  font: inherit;
  font-size: 12px;
  font-weight: 550;
  color: var(--ink);
  background: none;
  border: 0;
  border-radius: 7px;
  padding: 6px 9px;
  cursor: pointer;
}

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

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

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

## Settings

A plugin's settings are the options you pass when you register it, and each plugin page lists them
under **Settings**. Every plugin lets you change them while the app runs, for example when the user
picks another layout:

```ts
stage.updateSettings({ layout: 'grid' });
stage.resetSettings(); // back to what you registered
```

Every plugin with settings has the same three calls, a `settings()` signal, and a
`settingsChanged$` stream.

Colors are settings too, such as the highlight of a search match or the handles of an annotation.
Each color setting can also come from a CSS variable, which wins over the setting, for dark mode or a
different look per viewer: see [Colors & theming](../ui/theming).

## Checks before you act

Anything the user may not be allowed to do has a check with the same name:
`annotation.canCreate()`, `search.canSearch()`, `pageEdit.canEdit()`. Use them to hide or disable
buttons. The engine checks every call again, so they keep your UI honest; they're not the guard.
See [Permissions](permissions).

## Good to know

### Code outside components

Code that isn't a component, such as a keyboard handler, analytics or a test, uses the same
services: inject them in a service of your own, provided next to `provideEmbedPdf()`.

**`outside-components.ts`**

```typescript
import { Injectable, inject } from '@angular/core';
import { EpdfSearch } from '@embedpdf/angular/search';

/** Provide it next to the viewer: `providers: [provideEmbedPdf(…), SearchShortcuts]`. */
@Injectable()
export class SearchShortcuts {
  private readonly search = inject(EpdfSearch);

  handleKey(event: KeyboardEvent) {
    if (event.key === 'F3') this.search.nextHit();
  }
}
```

With `provideEmbedPdf()` in your app config, every service in the app can inject them. In a test,
replace one the Angular way: `{ provide: EpdfSearch, useValue: fakeSearch }`.

### Plugins don't touch the page

Plugins hold state and logic, never HTML. The framework packages draw. That's why every plugin
works the same in React, Vue, Svelte and Angular, and why you can use a plugin's API in a test
without a browser.

### Coordinates

Every position is in points from the top-left corner of the page, with y going down. Layers and
overlays convert to pixels for you. See [Coordinates](coordinates).
