A framework-agnostic floating feedback web component built with Lit. A small button sits in a corner of your page; users click it to either annotate a screenshot (select a region, draw on it, add a text description and/or an audio note) or record a video of the viewport. When they hit Send, the widget dispatches a DOM event with all artifacts as Blobs — your code decides where to upload them. The widget itself makes no network calls.
npm install @xmesh/feedback-widgetimport '@xmesh/feedback-widget';<feedback-widget></feedback-widget>Or without a bundler:
<script src="https://unpkg.com/@xmesh/feedback-widget"></script>
<feedback-widget></feedback-widget>Place the element as a direct child of <body>. Ancestors with transform, filter,
or perspective would break its fixed positioning.
| Attribute | Default | Description |
|---|---|---|
position |
bottom-right |
Corner for the launcher: bottom-right, bottom-left, top-right, top-left |
offset-x |
24 |
Horizontal offset from the corner, px |
offset-y |
24 |
Vertical offset from the corner, px |
z-index |
2147483000 |
Stacking order of the launcher and overlays |
accent-color |
#6d5cff |
Accent used for the launcher, buttons, and selection marquee |
capture-engine |
dom |
dom renders the DOM to an image (no browser prompt); native grabs real pixels via the Screen Capture API (pixel-perfect, one share prompt, Chromium desktop only — silently falls back to dom elsewhere) |
mask-selector |
— | Comma-separated CSS selectors hidden from screenshots (blank boxes, layout preserved). Applies to the dom engine only — native captures real pixels and cannot mask |
The accent is also exposed as a CSS custom property, so this works too:
feedback-widget {
--fw-accent: #e0342f;
}All events bubble and cross the shadow boundary (composed: true).
| Event | detail |
When |
|---|---|---|
feedback-open |
— | The launcher was clicked and the menu opened |
feedback-close |
— | The flow was closed, cancelled, or submitted |
feedback-submit |
FeedbackSubmitDetail |
The user pressed Send |
feedback-error |
{ stage, message, cause? } |
Capture, microphone, or recording failed |
interface FeedbackSubmitDetail {
type: 'annotation' | 'video';
screenshot?: Blob;
annotatedImage?: Blob;
region?: { x: number; y: number; width: number; height: number; devicePixelRatio: number };
description?: string;
audio?: Blob;
video?: Blob;
meta: {
url: string;
userAgent: string;
viewportWidth: number;
viewportHeight: number;
timestamp: string;
};
}For type: 'annotation' you get the full viewport screenshot (PNG), the cropped and
drawn-over annotatedImage (PNG), the selected region in CSS pixels with the device
pixel ratio, and optionally description text and an audio note (webm/opus). For
type: 'video' you get the video recording (webm) and optionally description.
import type { FeedbackSubmitDetail } from '@xmesh/feedback-widget';
const widget = document.querySelector('feedback-widget');
widget.addEventListener('feedback-submit', async (event) => {
const detail = event.detail as FeedbackSubmitDetail;
const form = new FormData();
form.append('meta', JSON.stringify(detail.meta));
if (detail.annotatedImage) form.append('image', detail.annotatedImage, 'annotated.png');
if (detail.audio) form.append('audio', detail.audio, 'note.webm');
if (detail.video) form.append('video', detail.video, 'recording.webm');
if (detail.description) form.append('description', detail.description);
await fetch('/api/feedback', { method: 'POST', body: form });
});The annotate flow is capture-first, like an OS screenshot tool: clicking "Annotate a screenshot" freezes a frame of the viewport, and the region selection happens on top of that frozen image, so what you select is exactly what you get.
With the default dom engine, the frame is produced by serializing the live DOM to a
canvas via snapDOM — no browser permission
prompt, nothing leaves the page, and shadow DOM (including adoptedStyleSheets, so Lit
and other web-component pages) is captured. The capture runs once, when "Annotate a
screenshot" is clicked, and the viewport is rasterized directly at full
device-pixel-ratio sharpness regardless of page length. Serialization cost scales with
the page's element count — dense multi-thousand-element pages take seconds; the escape
hatch for such pages is capture-engine="native". Consequences:
- Cross-origin images render only if they are served with CORS headers.
- Content inside cross-origin iframes, native video frames, and WebGL canvases may be
missing or black in the screenshot — use
capture-engine="native"on such pages.
With capture-engine="native", the widget uses the same mechanism as Sentry's feedback
widget: getDisplayMedia({ preferCurrentTab: true }), one video frame drawn to canvas,
tracks stopped immediately. Pixel-perfect, but the browser shows a one-click share
prompt, and the streamlined current-tab picker is Chromium-only. On unsupported
browsers (or insecure contexts and mobile) it silently falls back to the dom engine,
and if the share prompt is declined or blocked by policy the capture falls back to the
dom engine too — slower, but the flow never dead-ends on a dismissed dialog.
Video mode uses the Screen Capture API: the browser shows a share picker, and on
Chromium the current tab is preselected (preferCurrentTab). Recording stops via the
widget pill or the browser's own "Stop sharing" bar. The microphone toggle in the menu
mixes narration into the recording.
Evergreen Chromium, Firefox, and Safari. preferCurrentTab is Chromium-only — other
browsers show the generic share picker. Recording mime types fall back automatically
(vp9 → vp8 → webm → mp4).
npm install
npm run dev
npm test
npm run typecheck
npm run buildnpm run dev opens a demo page that logs every event and previews the submitted blobs.
Bump version in package.json, commit, then:
git tag v0.1.0
git push origin main --tagsGitHub Actions builds, tests, and publishes to npm with provenance. Requires an
NPM_TOKEN repository secret with publish rights for the @xmesh scope.
MIT