Your own annotation look
The annotation layer draws each annotation the way the PDF describes it. You can draw some of them with your own component instead: a note that looks like your app’s comment bubble, a stamp that shows live data from your server.
This changes the screen only. The PDF keeps the annotation’s own look, so other PDF apps, printing and downloads show the original:
| Where | What people see |
|---|---|
| Your viewer | Your component |
| The saved PDF, other apps, printing | The annotation’s own appearance |
Its look, or UI on it#
Ask whether it should zoom and turn with the PDF:
| You want | Use | It |
|---|---|---|
| A different look: a comment bubble instead of the note icon, a stamp with live data | A renderer | Is part of the page: it zooms and turns with it, and sits under the selection handles |
| A mark that stays on the annotation: a ✓, a status, a count of replies | Pinned anchored UI | Floats over the pages, upright and the same size at every zoom, exactly where you put it |
| A card or a menu next to it | Anchored UI | Floats over the pages, and moves to stay in view |
Draw an annotation your way#
Give the layer a list of renderers. Each one says which annotations it draws and with which component:import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/react/annotation';
import { CommentBubble } from './comment-bubble';
const renderers: AnnotationRenderer[] = [
{ for: (annotation) => annotation.subtype === 'text', component: CommentBubble },
];
export const Annotations = () => <AnnotationLayer renderers={renderers} />;for
gets the annotation, with all its fields, so you can pick by kind, name, author or anything else. The first renderer that
matches wins; an annotation no renderer matches is drawn as usual.
Define the list once, outside your component or in useMemo. The layer registers each entry, and
a new list every render would register them again (the viewer warns about it while you develop).
Your component draws into a frame: a box the layer places, turns and scales exactly like
the annotation’s own drawing, also while it’s dragged, resized or turned. Fill it, with width and
height at 100%, and draw at the annotation’s 100% size: the layer scales your drawing with the
page, text and borders included, as it does the PDF’s own:
import type { AnnotationRendererProps } from '@embedpdf/react/annotation';
// It fills its frame (`width` and `height` 100% in your CSS), so the selection
// outline and the click area match it, and it turns with the annotation.
export function CommentBubble({ annotation, hovered }: AnnotationRendererProps) {
return (
<div className={hovered ? 'bubble bubble--hover' : 'bubble'}>
{annotation.author?.[0] ?? '?'}
</div>
);
}The selection outline, the click area and the hover all use the frame, so a look that fills it matches them. A note keeps its size on screen as people zoom in and stays upright when the page is turned, and so does a look that fills its frame. To make the bubble bigger, make the note bigger: a size in points is a third more in pixels at 100% zoom, so a 24-point note is 32 pixels.
Your component gets:| Prop | What it is |
|---|---|
annotation | The annotation, as get() returns it. |
frame | The box you draw into: its width and height in pixels at the annotation’s 100% size, its rotation on screen in degrees clockwise, and the scale the layer draws it at. Turn your content by -rotation to keep it upright, such as a label on a turned stamp. |
native | The layer’s own drawing of it, filling the frame. Render it to keep the original look and add to it. |
appearance | The engine’s picture of the annotation as an image (url), or null. native already draws it in place. |
hovered | True while the pointer is over it. |
selected | True while it’s selected, such as to make room for the selection handles. |
interactive | True when what you draw takes the pointer (see below). |
Loading live preview…
import { useEffect, useRef, useState } from 'react';
import { Viewer, DocumentGate, usePageList } 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 { interactionPlugin } from '@embedpdf/react/interaction';
import {
AnnotationLayer,
annotationPlugin,
useAnnotation,
useAnnotationState,
type AnnotationRenderer,
type AnnotationRendererProps,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';
import './renderers.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
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 note drawn as your app's comment bubble, the author's initials in it. It
// fills its frame, so the selection outline and the click area match it, and
// it turns with the annotation: a note stays upright on a turned page.
function CommentBubble({ annotation, hovered }: AnnotationRendererProps) {
const initials = (annotation.author ?? '?')
.split(' ')
.map((word) => word[0])
.join('');
return <div className={hovered ? 'bubble bubble--hover' : 'bubble'}>{initials}</div>;
}
// Defined once, outside the component: the layer registers each entry.
const RENDERERS: AnnotationRenderer[] = [
{ for: (annotation) => annotation.subtype === 'text', component: CommentBubble },
];
const NONE: AnnotationRenderer[] = [];
// On load: two notes on the cover, 24 points square: 32 pixels at 100%.
function AddNotes() {
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const cover = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!ready || !cover || added.current) return;
added.current = true;
void annotation.create(cover, {
subtype: 'text',
rect: { x: 470, y: 228, width: 24, height: 24 },
contents: 'Can we shorten the title?',
color: '#facc15',
});
void annotation.create(cover, {
subtype: 'text',
rect: { x: 280, y: 516, width: 24, height: 24 },
contents: 'Add the co-author',
color: '#facc15',
});
}, [annotation, ready, cover]);
return null;
}
// A note keeps its size on screen and stays upright: turn the view to see it.
function TurnButton() {
const stage = useStage();
return (
<button type="button" className="button" onClick={() => stage.rotateViewBy(90)}>
Turn the view
</button>
);
}
export default function App() {
const [mine, setMine] = useState(true);
return (
<Viewer
engine={engine}
plugins={plugins}
identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
initialDocuments={[{ source: ebook }]}
>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddNotes />
<div className="toolbar">
<div className="segmented" role="group" aria-label="Look">
<button type="button" aria-pressed={mine} onClick={() => setMine(true)}>
Your look
</button>
<button type="button" aria-pressed={!mine} onClick={() => setMine(false)}>
The PDF’s look
</button>
</div>
<TurnButton />
</div>
<Stage className="stage">
{() => (
<>
<RenderLayer />
<AnnotationLayer renderers={mine ? RENDERERS : NONE} />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}Try it: the notes are bubbles with their author’s initials. Click one: the selection fits it. Press Turn the view: they stay upright, like the PDF’s own notes. Switch to The PDF’s look: that’s what the saved PDF shows.
People can still select, move, resize and delete the annotation: your component draws, and the viewer keeps handling the pointer. That’s also why it gets no clicks of its own. For that, see Take over the pointer.
Show a card on hover#
A hover card sits next to an annotation while the pointer is over it. It needs two things: the annotation under the pointer, and an anchor that keeps a card attached to it:
import { Anchored } from '@embedpdf/react/anchored';
import {
AnnotationLayer,
useAnnotationAnchor,
useAnnotationState,
} from '@embedpdf/react/annotation';
import { Stage } from '@embedpdf/react/stage';
function StampCard() {
const { hovered } = useAnnotationState(); // the annotation under the pointer, or null
const anchor = useAnnotationAnchor(hovered?.ref ?? null);
if (hovered?.subtype !== 'stamp') return null;
return (
<Anchored anchor={anchor} placement="top">
<div className="card">Approved by {hovered.author}</div>
</Anchored>
);
}
export const Pages = () => <Stage overlay={<StampCard />}>{() => <AnnotationLayer />}</Stage>;Loading live preview…
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 { Anchored } from '@embedpdf/react/anchored';
import {
AnnotationLayer,
annotationPlugin,
useAnnotation,
useAnnotationAnchor,
useAnnotationState,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';
import './hover-card.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
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()) };
};
// On load: two notes and a rectangle on the cover, each with a comment.
function AddAnnotations() {
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const cover = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!ready || !cover || added.current) return;
added.current = true;
void annotation.create(cover, {
subtype: 'text',
rect: { x: 470, y: 232, width: 20, height: 20 },
contents: 'Can we shorten the title?',
color: '#facc15',
});
void annotation.create(cover, {
subtype: 'text',
rect: { x: 280, y: 520, width: 20, height: 20 },
contents: 'Add the co-author',
color: '#facc15',
});
void annotation.create(cover, {
subtype: 'square',
box: { x: 96, y: 376, width: 360, height: 118 },
contents: 'This subtitle reads well',
color: '#30a46c',
strokeWidth: 3,
});
}, [annotation, ready, cover]);
return null;
}
// A card next to the annotation under the pointer: who wrote it, and what.
function HoverCard() {
const { hovered } = useAnnotationState(); // the annotation under the pointer, or null
const anchor = useAnnotationAnchor(hovered?.ref ?? null);
if (!hovered?.contents) return null;
return (
<Anchored anchor={anchor} placement="top">
<div className="card">
<strong>{hovered.author ?? 'Someone'}</strong>
<p>{hovered.contents}</p>
</div>
</Anchored>
);
}
export default function App() {
return (
<Viewer
engine={engine}
plugins={plugins}
identity={{ userId: 'u_381', displayName: 'Dana Smith' }}
initialDocuments={[{ source: ebook }]}
>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddAnnotations />
<p className="hint">Point at a note or the green rectangle</p>
<Stage className="stage" overlay={<HoverCard />}>
{() => (
<>
<RenderLayer />
<AnnotationLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}Mount it in the Stage’s overlay, like a menu. The card stays next to the annotation while the user scrolls and
zooms, and while the annotation is moved.
useAnnotationAnchor(ref) works for any annotation, not only the hovered one: see the next section.
Pin UI to an annotation#
A ✓ on an approved stamp, a status under it, a count of replies next to a note: UI that belongs to
one annotation and stays on it. pinned keeps it exactly where you put it. It doesn’t flip or move to
stay in view, and it scrolls away with the annotation:
import { Anchored } from '@embedpdf/react/anchored';
import { annotationKey, useAnnotationAnchor, type Annotation } from '@embedpdf/react/annotation';
import { useApproval } from './approvals'; // your own data
// Pinned under the stamp: it stays there while the stamp moves, and scrolls away with it.
export function ApprovalStatus({ stamp }: { stamp: Annotation }) {
const anchor = useAnnotationAnchor(stamp.ref);
const approval = useApproval(annotationKey(stamp.ref)); // your own data
return (
<Anchored anchor={anchor} placement="bottom" pinned>
<div className="status">
{approval.signedOff ? 'Signed off' : 'Waiting'}
<button onClick={approval.toggle}>{approval.signedOff ? 'Undo' : 'Sign off'}</button>
</div>
</Anchored>
);
}Mount one for each annotation that needs it in the Stage’s overlay,
from useAnnotationList(). Anchored UI on a page that isn’t on screen isn’t drawn, so a document
with hundreds of them costs only the ones in view.
Loading live preview…
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 { Anchored } from '@embedpdf/react/anchored';
import {
AnnotationLayer,
annotationPlugin,
useAnnotation,
useAnnotationAnchor,
useAnnotationList,
useAnnotationState,
type Annotation,
} from '@embedpdf/react/annotation';
import { localEngine } from '@embedpdf/engine';
import './stamp-status.css';
const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), interactionPlugin(), annotationPlugin()];
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()) };
};
// One stamp's status, pinned to it: a check on its corner once it's signed off,
// and a status with a button under it. Both stay there while the stamp moves.
function ApprovalStatus(props: { stamp: Annotation; signedOff: boolean; onToggle: () => void }) {
const anchor = useAnnotationAnchor(props.stamp.ref);
return (
<>
{props.signedOff && (
<Anchored anchor={anchor} placement="top-end" gap={-12} pinned>
<span className="check">✓</span>
</Anchored>
)}
<Anchored anchor={anchor} placement="bottom" gap={8} pinned>
<div className="status">
<span className={props.signedOff ? 'dot dot--done' : 'dot'} />
{props.signedOff ? 'Signed off' : 'Waiting'}
<button
type="button"
className={props.signedOff ? 'sign sign--undo' : 'sign'}
onClick={props.onToggle}
>
{props.signedOff ? 'Undo' : 'Sign off'}
</button>
</div>
</Anchored>
</>
);
}
// An approval stamp's name, which the stamp keeps in every PDF app; null for any other annotation.
const approvalName = (annotation: Annotation): string | null =>
annotation.subtype === 'stamp' && annotation.name?.startsWith('approval-')
? annotation.name
: null;
// Every approval stamp's status, mounted in the Stage's overlay. Your own data
// for each stamp is kept by its name.
function Approvals() {
const stamps = useAnnotationList({ subtype: 'stamp' });
const [signedOff, setSignedOff] = useState<Record<string, boolean>>({
'approval-budget': true,
});
return stamps.map((stamp) => {
const name = approvalName(stamp);
if (!name) return null;
return (
<ApprovalStatus
key={name}
stamp={stamp}
signedOff={signedOff[name] ?? false}
onToggle={() => setSignedOff((current) => ({ ...current, [name]: !current[name] }))}
/>
);
});
}
// On load: two approval stamps on the cover, one signed off. The second sits
// at the bottom, so its status hangs over the next page.
function AddStamps() {
const annotation = useAnnotation();
const ready = useAnnotationState((state) => state.status === 'ready');
const cover = usePageList()[0]?.ref;
const added = useRef(false);
useEffect(() => {
if (!ready || !cover || added.current) return;
added.current = true;
void approvedPicture().then(async (appearance) => {
await annotation.create(
cover,
{
subtype: 'stamp',
box: { x: 330, y: 470, width: 180, height: 60 },
name: 'approval-budget',
},
{ appearance },
);
await annotation.create(
cover,
{
subtype: 'stamp',
box: { x: 96, y: 712, width: 180, height: 60 },
name: 'approval-contract',
},
{ appearance },
);
});
}, [annotation, ready, cover]);
return null;
}
export default function App() {
return (
<Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
<DocumentGate fallback={<p className="loading">Loading…</p>}>
<AddStamps />
<p className="hint">Sign a stamp off, then move it: its status goes with it.</p>
<Stage className="stage" overlay={<Approvals />}>
{() => (
<>
<RenderLayer />
<AnnotationLayer />
</>
)}
</Stage>
</DocumentGate>
</Viewer>
);
}
// The stamp's picture, drawn on a canvas so the example needs no image file.
function approvedPicture(): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = 360;
canvas.height = 120;
const context = canvas.getContext('2d')!;
context.strokeStyle = '#30a46c';
context.lineWidth = 10;
context.strokeRect(5, 5, 350, 110);
context.fillStyle = '#30a46c';
context.font = 'bold 54px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('APPROVED', 180, 64);
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png'));
}Try it: sign the second stamp off, then select a stamp and move it: its status goes with it. The second stamp’s status hangs over the next page, and stays on top.
- The stamps are placed like any stamp, so the PDF holds the real ones; see Stamps.
- Each one is picked out by its
name, which the stamp keeps in every PDF app. - The status sits under the stamp with
placement="bottom", and the ✓ on its corner withplacement="top-end"and a negativegap. See Where it goes. - The stamp keeps its own look and stays selectable: the buttons are next to it, not in it.
To find your own data for an annotation, key it by annotationKey(annotation.ref), which stays the
same after the document is saved and opened again, or by its name when you gave it one.
Take over the pointer#
Sometimes your component must take clicks itself: buttons, links, a text field. Mark the
renderer interactive:
import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/react/annotation';
import { ApprovalWidget } from './approval-widget';
const renderers: AnnotationRenderer[] = [
{
for: (annotation) => annotation.subtype === 'stamp' && annotation.name === 'Approved',
component: ApprovalWidget,
interactive: true,
},
];
export const Annotations = () => <AnnotationLayer renderers={renderers} />;This is for when the annotation itself is the control, such as a stamp you tick like a checkbox. For buttons next to an annotation, pin them instead: the annotation stays selectable.
While a renderer is interactive, the pointer belongs to your component, and people can’t select, move or delete that annotation with it. Most apps want both: interactive while reading, editable while editing. Pass a function, and the viewer asks it whenever it matters:
import { AnnotationLayer, type AnnotationRenderer } from '@embedpdf/react/annotation';
import { ApprovalWidget } from './approval-widget';
const renderers: AnnotationRenderer[] = [
{
for: (annotation) => annotation.subtype === 'stamp' && annotation.name === 'Approved',
component: ApprovalWidget,
interactive: ({ toolId }) => toolId === 'pan', // interactive in reading mode only
},
];
export const Annotations = () => <AnnotationLayer renderers={renderers} />;Good to know#
Drawing at the size on screen#
A renderer with scale: false draws at the frame’s size on screen instead, and sizes its text and
borders itself from frame.scale. Use it for something that must lay itself out at full resolution,
such as a canvas.
Hover cards people can use#
A card closes when the pointer leaves the annotation, so the pointer can’t reach a button in it.
Keep the card open while the pointer is over the card itself, for example with a short delay
before it closes and a pointerenter on the card that cancels it.
Form fields#
A form field’s widgets aren’t annotations: they’re the form’s, and the <FormLayer> draws them, so
your renderers never get them. See Forms and annotations.
Other layers#
A renderer replaces the drawing inside the annotation layer. Anything that isn’t tied to one
annotation, such as a comments sidebar, belongs in your own UI, fed by useAnnotationList().
Your feedback goes directly to the documentation team.