---
title: "Menus over the page — Angular"
description: "Attach your own UI to a spot on a page, such as a selection, an annotation or a search match, and keep it there while people scroll and zoom."
framework: "Angular"
source: "https://www.embedpdf.com/docs/headless/angular/ui/anchored"
---

# Menus over the page

A menu next to selected text, a card next to an annotation, a button over a search match: UI that
belongs to a spot on the page, but floats above it. `<epdf-anchored>` places your UI at a spot, and keeps it
there while people scroll, zoom and rotate. Put it inside `<epdf-stage>`, next to the page template:

**`stage-overlay.ts`**

```typescript
import { Component, input } from '@angular/core';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import type { SearchHit } from '@embedpdf/angular/search';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
import { highlight } from './highlight';

@Component({
  selector: 'app-pages',
  imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfAnchored],
  template: `
    <epdf-stage>
      <ng-template epdfPage>
        <epdf-render-layer />
      </ng-template>

      <epdf-anchored [anchor]="{ page: hit().page, bounds: hit().bounds }" placement="bottom">
        <button (click)="highlight(hit())">Highlight</button>
      </epdf-anchored>
    </epdf-stage>
  `,
})
export class Pages {
  readonly hit = input.required<SearchHit>();
  protected readonly highlight = highlight;
}
```

The anchor is a box on a page, in page coordinates: the same `page` and `bounds` every plugin gives
you. The overlay moves with the view in the same frame as the pages, so your menu never lags behind.

**`overlay.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  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 { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import { localEngine } from '@embedpdf/engine';

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

// A button below the active match: it follows the match while you scroll and zoom.
@Component({
  selector: 'demo-next-match',
  imports: [EpdfAnchored],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <epdf-anchored [anchor]="anchor()" placement="bottom">
      <button type="button" class="pill" (click)="search.nextHit()">
        {{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }} · Next →
      </button>
    </epdf-anchored>
  `,
})
export class NextMatch {
  protected readonly search = inject(EpdfSearch);
  protected readonly anchor = computed(() => {
    const hit = this.search.activeHit();
    return hit && { page: hit.page, bounds: hit.bounds };
  });
}

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

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  private readonly search = inject(EpdfSearch);
  private readonly document = inject(EpdfDocument);

  constructor() {
    // Every "PDF" in the document, and the view on the first one.
    effect(() => {
      if (this.document.status() !== 'ready') return;
      void this.search.search({ text: 'PDF' }).then(() => this.search.revealActiveHit());
    });
  }
}
```

**`overlay.css`**

```css
/* Colors */
:root {
  --muted: #5a6b92;
  --line: #e6eaf2;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-strong: #043f8f;
  --on-accent: #ffffff;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The button that follows the match */
.pill {
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
  border-radius: 999px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 8px 20px rgb(7 32 76 / 0.25);
  transition: background-color 0.15s ease;
}

.pill:hover {
  background: var(--accent-strong);
}

.pill: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);
}
```

## Ready-made menus

Most menus follow something that's already on the page. These place themselves:

| Component                      | Attaches to                       | Page                                                                    |
| :----------------------------- | :-------------------------------- | :---------------------------------------------------------------------- |
| `<epdf-selection-menu>`        | The selected text                 | [Selecting & copying](../text/selection#add-a-selection-menu)           |
| `<epdf-annotation-menu>`       | The selected annotations          | [Selecting & editing](../annotations/selecting#add-a-menu)              |
| `<epdf-annotation-draft-menu>` | A polygon or polyline being drawn | [Selecting & editing](../annotations/selecting#shapes-with-many-points) |

They hide while people drag, and show when the selection settles. What's inside is yours.

## Attach to an annotation

`annotation.anchorOf(ref)` gives you an annotation's anchor, and follows it while it's moved. A count
of replies next to a note stays put, so it's `pinned`:

**`reply-count.ts`**

```typescript
import { Component, inject, input } from '@angular/core';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import { type Annotation, EpdfAnnotation, EpdfComments } from '@embedpdf/angular/annotation';

@Component({
  selector: 'app-reply-count',
  imports: [EpdfAnchored],
  template: `
    <epdf-anchored [anchor]="anchor()" placement="right" [gap]="4" pinned>
      <span class="badge">{{ thread()?.replies?.length ?? 0 }}</span>
    </epdf-anchored>
  `,
})
export class ReplyCount {
  readonly note = input.required<Annotation>();

  protected readonly anchor = inject(EpdfAnnotation).anchorOf(() => this.note().ref);
  protected readonly thread = inject(EpdfComments).threadOf(() => this.note().ref);
}
```

Pass the ref as a function, and the anchor and the thread follow the `note` input when it changes.

## Where it goes

| Prop        | What it is                                                                                                                                                            | Default  |
| :---------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------- |
| `anchor`    | `{ page, bounds }`, or `null` to hide. `avoid` adds points to keep clear of, such as a rotation handle                                                                | required |
| `placement` | `'top'`, `'bottom'`, `'left'` or `'right'` of the box, centred on that side, or lined up with its start or end: `'top-start'`, `'top-end'`, `'right-start'` and so on | `'top'`  |
| `gap`       | The space between the box and your UI, in pixels. Negative overlaps the box, for a badge on a corner                                                                  | `8`      |
| `pinned`    | Stays exactly where `placement` puts it: no flipping, no moving to stay in view                                                                                       | `false`  |

When there's no room on the side you chose, it flips to the other side, and it stays inside the view.
That's right for a menu. A badge or a status belongs on its spot, so pin it: it stays there and
scrolls away with its box. Scroll the match close to an edge, then turn on **Pinned**:

**`placement.ts`**

```typescript
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  inject,
  input,
  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 { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { EpdfAnchored } from '@embedpdf/angular/anchored';
import type { AnchoredPlacement, AnchoredSide } from '@embedpdf/angular/anchored';
import { localEngine } from '@embedpdf/engine';

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

const SIDES: AnchoredSide[] = ['top', 'right', 'bottom', 'left'];
const ALIGNS = ['start', 'center', 'end'] as const;

// A card next to the active match, where you put it, `gap` pixels away.
@Component({
  selector: 'demo-match-card',
  imports: [EpdfAnchored],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <epdf-anchored [anchor]="anchor()" [placement]="placement()" [gap]="gap()" [pinned]="pinned()">
      <div class="card">
        <span class="card-title">
          Match {{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}
        </span>
        <span class="card-page">Page {{ (search.activeHit()?.pageIndex ?? 0) + 1 }}</span>
        <div class="card-steps">
          <button
            type="button"
            class="step"
            aria-label="Previous match"
            (click)="search.previousHit()"
          >
            ←
          </button>
          <button type="button" class="step" aria-label="Next match" (click)="search.nextHit()">
            →
          </button>
        </div>
      </div>
    </epdf-anchored>
  `,
})
export class MatchCard {
  readonly placement = input.required<AnchoredPlacement>();
  readonly gap = input.required<number>();
  readonly pinned = input.required<boolean>();

  protected readonly search = inject(EpdfSearch);
  protected readonly anchor = computed(() => {
    const hit = this.search.activeHit();
    return hit && { page: hit.page, bounds: hit.bounds };
  });
}

@Component({
  selector: 'demo-root',
  imports: [
    EpdfDocumentGate,
    EpdfStage,
    EpdfPageTemplate,
    EpdfRenderLayer,
    EpdfSearchLayer,
    MatchCard,
  ],
  providers: [
    provideEmbedPdf(
      { engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
      withStage(),
      withRender(),
      withSearch(),
    ),
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrl: './placement.css',
  encapsulation: ViewEncapsulation.None,
  template: `
    <ng-container *epdfDocumentGate="let document; fallback: loading">
      <div class="toolbar">
        <div class="segmented" role="radiogroup" aria-label="Side">
          @for (each of sides; track each) {
            <button
              type="button"
              role="radio"
              class="segment"
              [attr.aria-checked]="each === side()"
              (click)="side.set(each)"
            >
              {{ each }}
            </button>
          }
        </div>
        <div class="segmented" role="radiogroup" aria-label="Along the side">
          @for (each of aligns; track each) {
            <button
              type="button"
              role="radio"
              class="segment"
              [attr.aria-checked]="each === align()"
              (click)="align.set(each)"
            >
              {{ each }}
            </button>
          }
        </div>
        <label class="range">
          Gap {{ gap() }} px
          <input
            #range
            type="range"
            min="-24"
            max="32"
            [value]="gap()"
            (input)="gap.set(range.valueAsNumber)"
          />
        </label>
        <label class="switch">
          <input
            #switch
            type="checkbox"
            [checked]="pinned()"
            (change)="pinned.set(switch.checked)"
          />
          Pinned
        </label>
      </div>
      <epdf-stage class="stage">
        <ng-template epdfPage>
          <epdf-render-layer />
          <epdf-search-layer />
        </ng-template>
        <demo-match-card [placement]="placement()" [gap]="gap()" [pinned]="pinned()" />
      </epdf-stage>
    </ng-container>

    <ng-template #loading><p class="loading">Loading…</p></ng-template>
  `,
})
export class App {
  protected readonly sides = SIDES;
  protected readonly aligns = ALIGNS;
  protected readonly side = signal<AnchoredSide>('top');
  protected readonly align = signal<(typeof ALIGNS)[number]>('center');
  protected readonly gap = signal(8);
  protected readonly pinned = signal(false);
  protected readonly placement = computed((): AnchoredPlacement => {
    const side = this.side();
    const align = this.align();
    return align === 'center' ? side : `${side}-${align}`;
  });

  private readonly search = inject(EpdfSearch);
  private readonly document = inject(EpdfDocument);

  constructor() {
    effect(() => {
      if (this.document.status() !== 'ready') return;
      void this.search.search({ text: 'PDF' }).then(() => this.search.revealActiveHit());
    });
  }
}
```

**`placement.css`**

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

/* Where the card goes, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 18px;
  margin-bottom: 14px;
}

.segmented {
  display: inline-flex;
  gap: 2px;
  padding: 3px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 11px;
  box-shadow: 0 1px 2px var(--shadow);
}

.segment {
  min-width: 58px;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 10px;
  cursor: pointer;
}

.segment:hover {
  color: var(--ink);
  background: var(--tint);
}

.segment[aria-checked='true'] {
  color: var(--accent);
  background: var(--accent-tint);
}

.range {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 550;
  color: var(--ink);
  cursor: pointer;
}

.switch input {
  width: 16px;
  height: 16px;
  margin: 0;
  accent-color: var(--accent);
}

/* The card next to the match */
.card {
  display: grid;
  grid-template-columns: auto auto;
  align-items: center;
  gap: 2px 14px;
  padding: 10px 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 10px 28px rgb(7 32 76 / 0.2);
}

.card-title {
  font-size: 13px;
  font-weight: 650;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.card-page {
  grid-row: 2;
  font-size: 12px;
  color: var(--muted);
}

.card-steps {
  grid-row: 1 / span 2;
  grid-column: 2;
  display: flex;
  gap: 4px;
}

.step {
  width: 30px;
  height: 30px;
  font: inherit;
  font-size: 14px;
  color: var(--ink);
  background: var(--tint);
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.step:hover {
  color: var(--accent);
  background: var(--accent-tint);
}

.segment:focus-visible,
.step: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);
}
```

## Good to know

### Only what's on screen costs anything

Anchored UI on a page that isn't on screen isn't drawn, and doesn't follow the scroll or the zoom.
Mount one for every annotation that needs it; a document with hundreds costs only the ones in view.

### Clicks stay in your menu

A click in an anchored menu never reaches the page underneath, so it doesn't clear the selection or
start a drag.

### In a PageView

Inside a [PageView](../viewing/page-view), anchored UI is placed above everything else on your page,
so a scrolling list or a card can't cut it off.
