---
title: "Menus over the page — React"
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: "React"
source: "https://www.embedpdf.com/docs/headless/react/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. `<Anchored>` places your UI at a spot, and keeps it
there while people scroll, zoom and rotate. Put it in the Stage's `overlay`:

**`stage-overlay.tsx`**

```tsx
import { Anchored } from '@embedpdf/react/anchored';
import { RenderLayer } from '@embedpdf/react/render';
import type { SearchHit } from '@embedpdf/react/search';
import { Stage } from '@embedpdf/react/stage';
import { highlight } from './highlight';

export function Pages({ hit }: { hit: SearchHit }) {
  return (
    <Stage
      overlay={
        <Anchored anchor={{ page: hit.page, bounds: hit.bounds }} placement="bottom">
          <button onClick={() => highlight(hit)}>Highlight</button>
        </Anchored>
      }
    >
      {() => <RenderLayer />}
    </Stage>
  );
}
```

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

```tsx
import { useEffect } from 'react';
import { Viewer, DocumentGate } 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 { SearchLayer, searchPlugin, useSearch, useSearchState } from '@embedpdf/react/search';
import { Anchored } from '@embedpdf/react/anchored';
import { localEngine } from '@embedpdf/engine';

import './overlay.css';

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

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.
function NextMatch() {
  const search = useSearch();
  const { activeHit, activeHitIndex, hitCount } = useSearchState();

  return (
    <Anchored
      anchor={activeHit && { page: activeHit.page, bounds: activeHit.bounds }}
      placement="bottom"
    >
      <button type="button" className="pill" onClick={() => search.nextHit()}>
        {activeHitIndex + 1} of {hitCount} · Next →
      </button>
    </Anchored>
  );
}

function Pages() {
  const search = useSearch();

  // Every "PDF" in the document, and the view on the first one.
  useEffect(() => {
    void search.search({ text: 'PDF' }).then(() => search.revealActiveHit());
  }, [search]);

  return (
    <Stage className="stage" overlay={<NextMatch />}>
      {() => (
        <>
          <RenderLayer />
          <SearchLayer />
        </>
      )}
    </Stage>
  );
}

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

**`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                                                                    |
| :---------------------- | :-------------------------------- | :---------------------------------------------------------------------- |
| `<SelectionMenu>`       | The selected text                 | [Selecting & copying](../text/selection#add-a-selection-menu)           |
| `<AnnotationMenu>`      | The selected annotations          | [Selecting & editing](../annotations/selecting#add-a-menu)              |
| `<AnnotationDraftMenu>` | 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

`useAnnotationAnchor(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.tsx`**

```tsx
import { Anchored } from '@embedpdf/react/anchored';
import { type Annotation, useAnnotationAnchor, useCommentThread } from '@embedpdf/react/annotation';

export function ReplyCount({ note }: { note: Annotation }) {
  const anchor = useAnnotationAnchor(note.ref);
  const thread = useCommentThread(note.ref);

  return (
    <Anchored anchor={anchor} placement="right" gap={4} pinned>
      <span className="badge">{thread?.replies.length ?? 0}</span>
    </Anchored>
  );
}
```

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

```tsx
import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } 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 { SearchLayer, searchPlugin, useSearch, useSearchState } from '@embedpdf/react/search';
import { Anchored } from '@embedpdf/react/anchored';
import type { AnchoredPlacement, AnchoredSide } from '@embedpdf/react/anchored';
import { localEngine } from '@embedpdf/engine';

import './placement.css';

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

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.
function MatchCard(props: { placement: AnchoredPlacement; gap: number; pinned: boolean }) {
  const search = useSearch();
  const { activeHit, activeHitIndex, hitCount } = useSearchState();

  return (
    <Anchored
      anchor={activeHit && { page: activeHit.page, bounds: activeHit.bounds }}
      placement={props.placement}
      gap={props.gap}
      pinned={props.pinned}
    >
      <div className="card">
        <span className="card-title">
          Match {activeHitIndex + 1} of {hitCount}
        </span>
        <span className="card-page">Page {(activeHit?.pageIndex ?? 0) + 1}</span>
        <div className="card-steps">
          <button
            type="button"
            className="step"
            aria-label="Previous match"
            onClick={() => search.previousHit()}
          >
            ←
          </button>
          <button
            type="button"
            className="step"
            aria-label="Next match"
            onClick={() => search.nextHit()}
          >
            →
          </button>
        </div>
      </div>
    </Anchored>
  );
}

function Pages() {
  const search = useSearch();
  const [side, setSide] = useState<AnchoredSide>('top');
  const [align, setAlign] = useState<(typeof ALIGNS)[number]>('center');
  const [gap, setGap] = useState(8);
  const [pinned, setPinned] = useState(false);
  const placement: AnchoredPlacement = align === 'center' ? side : `${side}-${align}`;

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

  return (
    <>
      <div className="toolbar">
        <div className="segmented" role="radiogroup" aria-label="Side">
          {SIDES.map((each) => (
            <button
              key={each}
              type="button"
              role="radio"
              aria-checked={each === side}
              className="segment"
              onClick={() => setSide(each)}
            >
              {each}
            </button>
          ))}
        </div>
        <div className="segmented" role="radiogroup" aria-label="Along the side">
          {ALIGNS.map((each) => (
            <button
              key={each}
              type="button"
              role="radio"
              aria-checked={each === align}
              className="segment"
              onClick={() => setAlign(each)}
            >
              {each}
            </button>
          ))}
        </div>
        <label className="range">
          Gap {gap} px
          <input
            type="range"
            min={-24}
            max={32}
            value={gap}
            onChange={(event) => setGap(Number(event.target.value))}
          />
        </label>
        <label className="switch">
          <input
            type="checkbox"
            checked={pinned}
            onChange={(event) => setPinned(event.target.checked)}
          />
          Pinned
        </label>
      </div>
      <Stage
        className="stage"
        overlay={<MatchCard placement={placement} gap={gap} pinned={pinned} />}
      >
        {() => (
          <>
            <RenderLayer />
            <SearchLayer />
          </>
        )}
      </Stage>
    </>
  );
}

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

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