A lightweight, drop-in widget for collecting user feedback on any website. Users click a floating button, hover to highlight elements on the page, click a target, write a comment, and submit. Entries are stored in PostgreSQL via a small Express router you mount in your existing backend.
6KB gzipped. Zero runtime dependencies. Framework-agnostic. Shadow DOM isolated.
See also: vent-widget — companion project for the agent side of feedback capture (MCP server that lets AI agents log friction to your repo as markdown). sticky-widget handles user feedback; vent-widget handles agent feedback. Same mission, different actor.
sticky-widget.mp4
- A floating chat button appears in the bottom-right corner of your page
- A user clicks it to activate — the cursor becomes a crosshair
- They hover over any element; meaningful components are automatically highlighted
- They click the element they want to comment on (or select text first)
- A small popover appears for them to type their comment
- On submit, the entry is POSTed to your backend and stored in Postgres
No page reload. No third-party services required. Widget CSS is fully isolated via Shadow DOM.
npm install sticky-widgetpsql $DATABASE_URL -f node_modules/sticky-widget/server/schema.sqlThis creates a single feedback table.
const { Pool } = require('pg');
const { stickyRouter } = require('sticky-widget/server');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use('/api/v1', stickyRouter(pool));The router adds two endpoints:
POST /api/v1/feedback— receives and stores an entryGET /api/v1/feedback— returns all entries (useful for internal dashboards)
Option A — CDN (jsDelivr auto-mirrors from npm):
<script src="https://cdn.jsdelivr.net/npm/sticky-widget@1.0.0/dist/sticky-widget.min.js"
onload="StickyWidget.init({ postUrl: '/api/v1/feedback' })"></script>Option B — from your own bundle:
import StickyWidget from 'sticky-widget';
StickyWidget.init({ postUrl: '/api/v1/feedback' });git clone https://github.com/CharlesTThe/sticky-widget.git
cd sticky-widget
npm install
npm run build
# In one terminal — start the backend:
DATABASE_URL=postgres://localhost/sticky_demo npm run server
# In another terminal — serve the demo page:
npm run dev
# Open the URL printed by `npm run dev` and try the widget.The demo POSTs to http://localhost:3001/api/v1/feedback. If you don't have Postgres handy, run the demo without the backend — entries are kept in memory and the toast still shows.
All options are passed to StickyWidget.init().
| Option | Type | Default | Description |
|---|---|---|---|
postUrl |
string |
null |
URL to POST entries to. If omitted, entries are only stored in-memory. |
onSubmit |
function |
null |
Called with the entry after submit. Useful for analytics or custom storage. |
onError |
function |
null |
Called with (error, entry) when a POST fails. |
onOpen |
function |
null |
Called when the widget is activated. |
onClose |
function |
null |
Called when the widget is deactivated. |
user |
object |
null |
{ name, email } — attached to every entry. Pass from your auth layer (e.g. Okta). |
captureConsole |
boolean |
true |
Capture console.error/warn and unhandled errors (last 50 entries). |
accentColor |
string |
#4f46e5 |
Hex color for the FAB, highlights, and submit button. |
position |
string |
bottom-right |
FAB position. Use bottom-left to move it. |
zIndexBase |
number |
99999 |
Base z-index. Increase if other elements overlap the widget. |
Example with all options:
StickyWidget.init({
postUrl: '/api/v1/feedback',
accentColor: '#0f766e',
position: 'bottom-right',
zIndexBase: 99999,
user: { name: 'Jane Doe', email: 'jane@example.com' },
captureConsole: true,
onSubmit: (entry) => console.log('New entry:', entry),
onError: (err, entry) => console.error('POST failed:', err),
onOpen: () => console.log('Widget activated'),
onClose: () => console.log('Widget deactivated'),
});StickyWidget.open() // programmatically activate the widget
StickyWidget.close() // programmatically deactivate the widget
StickyWidget.getEntries() // returns array of all in-memory entries
StickyWidget.clearEntries() // clears in-memory store
StickyWidget.destroy() // removes the widget from the page entirelyEach entry stored in Postgres (and passed to onSubmit) looks like this:
{
"id": "a1b2c3d4-...",
"timestamp": "2025-03-17T10:30:00.000Z",
"pageUrl": "https://yourapp.com/dashboard",
"target": {
"selector": "section#dashboard > div.card:nth-child(2)",
"tagName": "div",
"id": "revenue-card",
"label": "Total Revenue",
"rect": { "top": 120, "left": 240, "width": 280, "height": 140 }
},
"selectedText": "This number looks wrong",
"comment": "The revenue figure doesn't match the report from last week.",
"metadata": {
"userAgent": "Mozilla/5.0 ...",
"viewport": "1280x720",
"screenSize": "1920x1080",
"language": "en-US",
"url": "https://yourapp.com/dashboard",
"referrer": null,
"timestamp": "2025-03-17T10:30:00.000Z"
},
"user": { "name": "Jane Doe", "email": "jane@example.com" },
"consoleLogs": [
{ "level": "error", "message": "TypeError: ...", "timestamp": "2025-03-17T10:29:55.000Z" }
]
}| Field | Description |
|---|---|
id |
UUID, unique per submission |
timestamp |
ISO 8601, set client-side at moment of submission |
pageUrl |
Full URL of the page |
target.selector |
CSS selector path to the element — lets you programmatically find it again |
target.label |
Best available human-readable label (aria-label, inner text snippet) |
target.rect |
Position and size of the element at time of submission |
selectedText |
The text the user highlighted before clicking, if any |
comment |
The user's comment |
metadata |
Browser info auto-captured at submission time |
user |
User identity (only present if user config is set) |
consoleLogs |
Recent console errors/warnings captured before submission |
-- All entries for a specific page
SELECT * FROM feedback
WHERE page_url = 'https://yourapp.com/dashboard'
ORDER BY created_at DESC;
-- Most commented-on elements
SELECT label, selector, COUNT(*) as count
FROM feedback
GROUP BY label, selector
ORDER BY count DESC
LIMIT 20;
-- Entries with selected text
SELECT * FROM feedback
WHERE selected_text IS NOT NULL
ORDER BY created_at DESC;curl https://yourapp.com/api/v1/feedback -H 'X-API-Key: your-secret'Returns a JSON array of all entries ordered by most recent first (capped at 200 rows).
By default, the widget uses smart heuristics to find the nearest meaningful element when a user clicks. You can override this by marking elements explicitly:
<div data-sticky-id="revenue-chart">
<!-- clicking anywhere inside always targets this element -->
</div>This is useful for custom components where the auto-detection doesn't land on the right element.
The Express server supports these security features via environment variables:
| Env var | Description |
|---|---|
CORS_ORIGIN |
Restrict which origins can POST. Warns if not set. |
API_SECRET |
Optional. When set, GET /feedback requires an X-API-Key header. |
Additional protections:
- Rate limiting: 30 requests/min per IP
- Body size limit: 16KB
- Input validation: field length limits enforced server-side
- Helmet security headers
The POST endpoint is unauthenticated by default (rate-limited instead). For public-facing apps, add auth middleware before mounting the router.
The widget appends to document.body and manages its own DOM. In a single-page app, you need to manage its lifecycle correctly to avoid errors during page navigation.
Since the widget is page-agnostic, mount it in a component that persists across route changes (e.g. your app shell or layout). This avoids destroy/re-init on every navigation:
import { useEffect, useRef } from 'react';
import StickyWidget from 'sticky-widget';
function useStickyWidget(options) {
const initializedRef = useRef(false);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
StickyWidget.init(options);
return () => {
initializedRef.current = false;
StickyWidget.destroy();
};
}, []);
}
// In your app shell:
function AppShell({ children }) {
useStickyWidget({
postUrl: '/api/v1/feedback',
user: { name: 'Jane', email: 'jane@example.com' },
});
return <div>{children}</div>;
}If different pages need different widget options, mount/destroy per page — but always import as an ES module (not via <script> tags, which have unreliable onload behavior for cached scripts in SPAs):
import { useEffect } from 'react';
import StickyWidget from 'sticky-widget';
function FeedbackPage() {
useEffect(() => {
StickyWidget.init({ postUrl: '/api/v1/feedback' });
return () => StickyWidget.destroy();
}, []);
return <div>...</div>;
}- Always call
destroy()before React unmounts the page. TheuseEffectcleanup function handles this automatically. - Import as an ES module, not via dynamic
<script>insertion. Cached scripts may not re-fireonloadon SPA navigation. - Do not call
init()from anonClosecallback. This causes re-entrant initialization during teardown. If you need to re-initialize with different options, do so in a separateuseEffectorsetTimeout. destroy()is idempotent — calling it multiple times is safe (React 18 Strict Mode double-invokes effects in development).
Does it work on React / Vue / Angular apps?
Yes. The widget is a plain JS IIFE that manipulates the DOM directly. It doesn't know or care what framework rendered the page. Call StickyWidget.init() once after the page mounts.
Can I use this without a backend at all?
Yes — omit postUrl and use onSubmit to handle the entry yourself (e.g. send to a third-party API, log it, store in localStorage). The widget works entirely in-memory without a server.
Do I need to use Express?
The included server router requires Express. If you use a different framework (Fastify, Koa, Hapi), use server/router.js as a reference — it's about 30 lines of logic to re-implement.
The widget highlights the wrong element — how do I fix it?
Add data-sticky-id="your-label" to the element you want targeted. This always takes priority over the auto-detection heuristics.
How do I hide the widget for certain users?
Don't call StickyWidget.init() for those users. Or call StickyWidget.destroy() after init to remove it conditionally.
Can I trigger the widget programmatically?
Yes: StickyWidget.open() and StickyWidget.close().
What browsers are supported? Any modern browser (Chrome, Firefox, Safari, Edge). The bundle targets ES2020. IE is not supported.
How do I change the button position or color?
Pass position: 'bottom-left' and accentColor: '#your-hex' to init().
Is the data stored securely?
The widget stores entries in your own Postgres database — nothing goes to a third party. You control the data entirely. Set API_SECRET to protect the GET endpoint.