Drop-in feedback widget for any web page. A floating trigger button lets your users click anywhere on the page, capture a 400×200 thumbnail of the area, and attach a comment. Pins persist in sessionStorage by default — no backend required.
goboldlyforward.github.io/pinpoint — open the trigger, click Drop a pin, then click anywhere on the page.
- A round trigger button floats in a corner of your page (configurable: any of the four corners).
- Click it: the settings panel opens and pin mode is on. Click anywhere on the page to drop a pin.
- A 400×200 screenshot of the area around the click is captured (via html2canvas, lazy-loaded from a CDN on first use).
- A composer popover opens at the pin location for a comment. Save with the button or
Cmd/Ctrl + Enter. - Pins render as numbered teardrop markers on the page. Click a marker any time to read or delete it.
- Feedback about a whole page rather than a point on it goes in the panel's Page note zone: paste a screenshot from the clipboard, drop an image file on it, or click to browse. Notes take a comment like a pin does, but carry no coordinates and drop no marker.
- Click the trigger again (or the panel's X) to close the panel and exit pin mode.
- Everything persists in
sessionStorage(configurable tolocalStorageor in-memory).
No framework, no build step, no backend. Drop in the CSS + JS and you're done.
npm install @goboldlyforward/pinpointOr grab the files directly:
<link rel="stylesheet" href="path/to/pinpoint.css">
<script src="path/to/pinpoint.js"></script><script>
const pinpoint = new Pinpoint({
position: 'bottom-left',
storage: 'session',
screenshot: true,
keyboardTrigger: 'shift+meta+f',
});
</script>That's all the integration. The widget mounts itself when the DOM is ready.
new Pinpoint({
position: 'bottom-left', // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
storage: 'session', // 'session' | 'local' | 'memory'
storageKey: 'pinpoint:pins',
screenshot: true,
screenshotWidth: 400, // px; min 50
screenshotHeight: 200, // px; min 50
html2canvasUrl: 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js',
keyboardTrigger: null, // e.g. 'shift+meta+f'
notes: true, // show the page-note drop zone
noteMaxEdge: 1600, // px cap on a note image's long edge; min 200
noteQuality: 0.85, // JPEG quality for note images; 0 < q <= 1
noteMaxBytes: 2097152, // byte ceiling for the encoded note image
autoStart: true,
showMarkers: true,
onPinAdd: (pin) => {},
onPinDelete: (pin) => {},
onPinClick: (pin) => {},
});pinpoint.enable(); // enter pin mode
pinpoint.disable(); // leave pin mode
pinpoint.toggle();
pinpoint.setPosition('top-right');
pinpoint.getPins(); // returns shallow copies
pinpoint.deletePin(id);
pinpoint.clear();
pinpoint.exportJSON(); // returns JSON string
pinpoint.exportFile(); // downloads pinpoint-pins.json
pinpoint.importJSON(data);
pinpoint.destroy();{
id: 'pin-l9wq4z-x4f2k1',
kind: 'pin', // 'pin' | 'note'
x: 452, // px from document left
y: 1280, // px from document top
xPercent: 0.31, // x as fraction of document width
yPercent: 0.78, // y as fraction of document height
viewport: { width: 1440, height: 900 },
document: { width: 1440, height: 1640 },
body: 'Misaligned button',
thumbnail: 'data:image/png;base64,…', // 400x200 PNG, or null
pageUrl: 'https://example.com/page',
pageTitle: 'Example',
createdAt: '2026-05-23T14:02:11.000Z',
}A note is the same shape with kind: 'note', an id prefixed note-, null
for all four coordinates, and a thumbnail holding the whole screenshot rather
than a crop. Pins saved before this field existed have no kind at all and are
treated as 'pin'.
Sometimes the feedback is about the screen, not a spot on it. With the panel open, the Page note zone takes an image three ways:
- Paste —
Cmd/Ctrl + Vanywhere. On macOSCmd + Ctrl + Shift + 4puts a screenshot straight on the clipboard, so this is usually the fastest route. - Drop — drag an image file onto the zone.
- Click — opens a file picker.
Whatever arrives is re-encoded before it becomes a note: the long edge is capped
at noteMaxEdge and the image is written out as JPEG at noteQuality. If the
result still exceeds noteMaxBytes, quality drops and then dimensions shrink
until it fits, or the drop is refused with an inline message. Retina screenshots
are routinely several megabytes; most backends will not take that.
Notes flow through onPinAdd exactly like pins, so a backend already receiving
pins receives notes with no change beyond reading kind.
Pinpoint is local-only by default. To send pins somewhere, wire up onPinAdd:
new Pinpoint({
onPinAdd: async (pin) => {
await fetch('/api/pins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pin),
});
},
});The pin is already in storage by the time the hook fires, so a failed POST won't lose the pin — the user can retry from the panel's Export button.
Esc— cancel pin mode / close the composer / close the panelCmd/Ctrl + Enter— save the current commentkeyboardTriggeroption — global hotkey to toggle pin mode (e.g.'shift+meta+f')
Pinpoint lazy-loads html2canvas from jsDelivr the first time a pin is dropped. If the CDN is blocked, or if screenshot: false is set, pins save without a thumbnail. The screenshot ignores the Pinpoint widget itself so it doesn't appear in the capture.
Page notes need none of this — the image comes from the user's clipboard or disk,
so html2canvas is never loaded for them and screenshot: false does not disable
them. Set notes: false for that.
HTML, CSS, and ~10KB of JavaScript. No framework, no build step. Uses pointer/keyboard events, sessionStorage/localStorage, and a lazy-loaded copy of html2canvas.
MIT — see LICENSE.