I’ve used and tweaked this feedback pin system on my projects. Great for having users test and be able to just drop a pin wherever they have an issue/question - and then add annotation. Thought it might be helpful to other people here! I’ve modified it to show all feedback in Admin when I’ve needed it so I can check off the fixes/debugs/improvements one by one. Not a coder - so, I’m sure it’s not as well-written as it could be - but it works.
Feedback Pin System — Implementation Guide
Feedback pin system that lets logged-in users drop pins anywhere on a page, leave comments, and manage feedback. Admins can see all feedback site-wide; regular users see only their own.
1. Database Schema
Create a site_annotations table (Drizzle ORM + PostgreSQL):
import { pgTable, serial, varchar, text, integer, timestamp } from “drizzle-orm/pg-core”;
import { createInsertSchema } from “drizzle-zod”;
import { z } from “zod”;
export const siteAnnotations = pgTable(“site_annotations”, {
id: serial(“id”).primaryKey(),
userId: varchar(“user_id”).notNull(),
userName: text(“user_name”),
pagePath: text(“page_path”).notNull(),
elementSelector: text(“element_selector”),
xPercent: integer(“x_percent”).notNull(),
yPercent: integer(“y_percent”).notNull(),
comment: text(“comment”).notNull(),
status: text(“status”).default(“open”).notNull(),
createdAt: timestamp(“created_at”).defaultNow(),
});
export const insertSiteAnnotationSchema = createInsertSchema(siteAnnotations).omit({
id: true,
createdAt: true,
});
export type InsertSiteAnnotation = z.infer;
export type SiteAnnotation = typeof siteAnnotations.$inferSelect;
Field notes
- xPercent and yPercent store absolute pixel coordinates (scrollX + clientX, scrollY + clientY) — not percentages despite the column names. This ensures pins stay exactly where they were placed regardless of viewport size.
- pagePath stores the URL path (e.g. /, /admin/orders, /products/123). Annotations are scoped to the page they were created on — they only appear on that specific path and are never shown on other pages.
- status is either “open” or “resolved”.
2. Storage Interface
Add these methods to your storage interface and implement them with Drizzle:
interface IStorage {
getAnnotations(): Promise<SiteAnnotation>;
getAnnotation(id: number): Promise<SiteAnnotation | undefined>;
getAnnotationsByPage(pagePath: string): Promise<SiteAnnotation>;
getAnnotationsByUser(userId: string): Promise<SiteAnnotation>;
createAnnotation(annotation: InsertSiteAnnotation): Promise;
updateAnnotationStatus(id: number, status: string): Promise<SiteAnnotation | undefined>;
deleteAnnotation(id: number): Promise;
}
Drizzle implementation
import { eq, desc } from “drizzle-orm”;
async getAnnotations() {
return db.select().from(siteAnnotations).orderBy(desc(siteAnnotations.createdAt));
}
async getAnnotation(id: number) {
const [result] = await db.select().from(siteAnnotations).where(eq(siteAnnotations.id, id));
return result;
}
async getAnnotationsByPage(pagePath: string) {
return db
.select()
.from(siteAnnotations)
.where(eq(siteAnnotations.pagePath, pagePath))
.orderBy(siteAnnotations.createdAt);
}
async getAnnotationsByUser(userId: string) {
return db
.select()
.from(siteAnnotations)
.where(eq(siteAnnotations.userId, userId))
.orderBy(desc(siteAnnotations.createdAt));
}
async createAnnotation(annotation: InsertSiteAnnotation) {
const [result] = await db.insert(siteAnnotations).values(annotation).returning();
return result;
}
async updateAnnotationStatus(id: number, status: string) {
const [result] = await db
.update(siteAnnotations)
.set({ status })
.where(eq(siteAnnotations.id, id))
.returning();
return result;
}
async deleteAnnotation(id: number) {
await db.delete(siteAnnotations).where(eq(siteAnnotations.id, id));
}
3. API Routes
Create these endpoints (e.g. server/routes/annotations.ts). All require authentication. The CSV export requires admin.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/annotations | User | Returns all annotations (admin) or user’s own |
| GET | /api/annotations/page?path=… | User | Returns annotations for a specific page path. Admins see all; users see only their own. |
| POST | /api/annotations | User | Creates a new annotation. Server attaches userIdand userName from session. Body: { pagePath, xPercent, yPercent, comment } |
| PATCH | /api/annotations/:id | User | Updates status. Body: { status }. Users can only update their own; admins can update any. |
| DELETE | /api/annotations/:id | User | Deletes an annotation. Users can only delete their own; admins can delete any. |
| GET | /api/annotations/export | Admin | Returns a CSV file of all annotations. |
Important: page-scoped queries
The GET /api/annotations/page endpoint is the primary fetch used by the widget. It must filter by pagePath so that pins only appear on the page where they were created. Never return all annotations for rendering — always filter by the current page path.
router.get(“/annotations/page”, requireAuth, async (req, res) => {
const pagePath = req.query.path as string;
if (!pagePath) return res.status(400).json({ error: “path query parameter required” });
const allForPage = await storage.getAnnotationsByPage(pagePath);
// Admins see all pins on the page; regular users see only their own
const isAdmin = req.user.role === “admin”;
const annotations = isAdmin
? allForPage
: allForPage.filter((a) => a.userId === req.user.id);
res.json(annotations);
});
Important: ownership checks on mutations
Users must only be able to update or delete their own annotations. Always verify ownership server-side:
router.delete(“/annotations/:id”, requireAuth, async (req, res) => {
const annotation = await storage.getAnnotation(Number(req.params.id));
if (!annotation) return res.status(404).json({ error: “Not found” });
const isAdmin = req.user.role === “admin”;
if (annotation.userId !== req.user.id && !isAdmin) {
return res.status(403).json({ error: “Forbidden” });
}
await storage.deleteAnnotation(annotation.id);
res.json({ success: true });
});
Register routes in your main routes file:
import { registerAnnotationRoutes } from “./routes/annotations”;
registerAnnotationRoutes(app, requireAdmin, requireAuth);
4. Frontend Component: AnnotationWidget
A single React component mounted globally in App.tsx so it appears on every page. It manages several modes/states.
Dependencies
- lucide-react icons: MessageSquarePlus, X, Send, MapPin, CheckCircle2, Trash2, Download, Sparkles, MousePointerClick, MessageCircle, ArrowRight
- @tanstack/react-query for data fetching
- Your auth context (needs a user object with id, role, and name or email)
Core Behavior
A. Floating Action Button (FAB)
- Fixed position bottom-right corner, z-index 10001
- Shows a MessageSquarePlus icon (amber-800 background)
- Pulse/glow animation on first visit per session (use a sessionStorage key to track dismissal)
- On hover, shows a tooltip explaining the feature
- Click toggles “feedback mode” on/off
B. Feedback Mode (active = true)
- Shows control buttons: “View All / My Feedback” (opens sidebar list) and “+ Add Feedback” (starts pin placement)
- Renders an absolute-positioned overlay spanning the full scroll height of the page
- Overlay z-index: 9998, pointer-events: none
- Pins are fetched only for the current page path via GET /api/annotations/page?path=. Pins from other pages are never loaded or rendered.
- Existing pins render as colored circles with MapPin icons:
- Amber = open status
- Green = resolved status
- Clicking a pin toggles its popup card showing the comment, author, date, and action buttons (Resolve/Reopen, Delete)
C. Pin Placement Mode (placing = true)
- Top banner: “Click anywhere on the page to place a feedback pin” with a Cancel button (z-index 10000)
- Cursor changes to crosshair (document.body.style.cursor = “crosshair”)
- Click handler captures absolute coordinates: x = e.clientX + window.scrollX, y = e.clientY + window.scrollY
- After clicking, shows a PendingPinCard at the clicked position with:
- A blue pin marker
- A textarea for the comment
- Cancel and Save buttons
- Cmd/Ctrl+Enter keyboard shortcut to submit
- On save, the annotation is created via POST /api/annotations with the current page path and absolute coordinates
- On cancel, the pending pin disappears immediately with no data saved
D. Feedback List Sidebar (showList = true)
- Fixed right sidebar (z-index 10000), 384px wide
- Shows all annotations (admin) or user’s own
- Each item shows: page path, status badge, comment text, author, date
- Action buttons per item: Resolve/Reopen toggle, Delete
- Admin gets a CSV export download link
E. Onboarding Helper
- 2-step tutorial card that appears on first activation
- Step 1: How to use (3 numbered steps with icons)
- Step 2: Example feedback prompts (customize for your product)
- Pagination dots and Next/Done buttons
- Appears at bottom-right above the FAB
5. Pin Positioning — Making Pins Stay Where They Are Placed
This is the most critical part of the system. Pins must appear at the exact spot the user clicked, regardless of scrolling, viewport resizing, or page revisits.
Coordinate system
Pins use absolute pixel coordinates relative to the full document (not the viewport):
// On click — capture absolute position
const x = Math.round(e.clientX + window.scrollX);
const y = Math.round(e.clientY + window.scrollY);
These values are stored in the database as integers (xPercent / yPercent columns).
Rendering pins in the correct position
Pins are rendered inside an absolute-positioned overlay that matches the full document scroll height. This overlay is the coordinate space that makes stored positions line up:
function PinOverlay({ annotations, onPinClick, selectedId }) {
const [docHeight, setDocHeight] = useState(document.documentElement.scrollHeight);
useEffect(() => {
const ro = new ResizeObserver(() => {
setDocHeight(document.documentElement.scrollHeight);
});
ro.observe(document.documentElement);
return () => ro.disconnect();
}, );
return (
<divstyle={{
position: “absolute”,
top: 0,
left: 0,
width: “100%”,
height: ${docHeight}px,
pointerEvents: “none”,
zIndex: 9998,
}}
{annotations.map((ann) => (
<divkey={ann.id}
onClick={() => onPinClick(ann.id)}
style={{
position: “absolute”,
left: ${ann.xPercent}px,
top: ${ann.yPercent}px,
pointerEvents: “auto”,
cursor: “pointer”,
transform: “translate(-50%, -100%)”,
}}
<MapPin
size={28}
fill={ann.status === “resolved” ? “#22c55e” : “#f59e0b”}
color=“white”
/>
))}
);
}
Why absolute pixels (not percentages)
- Percentage-based positioning breaks when the page layout changes (e.g. sidebar toggled, content loaded dynamically, different screen sizes).
- Absolute pixel coordinates are stable as long as the page content above the pin remains the same height.
- The ResizeObserver on document.documentElement keeps the overlay height in sync if content changes dynamically.
Tracking document height changes
Use a ResizeObserver on document.documentElement to update the overlay height whenever the page content grows or shrinks:
useEffect(() => {
const ro = new ResizeObserver(() => {
setDocHeight(document.documentElement.scrollHeight);
});
ro.observe(document.documentElement);
return () => ro.disconnect();
}, );
6. Data Lifecycle — When Pins Persist and When They Don’t
Pins persist when:
- The user clicks Save after placing a pin and entering a comment. Only then is a POST /api/annotations call made and the pin stored in the database.
- The page is revisited — pins are fetched from the API on each page load, filtered by the current page path.
Pins do NOT persist when:
- The user is in pin placement mode but cancels before saving. No API call is made. The pending pin UI is discarded immediately.
- The user starts typing a comment but navigates away or closes the card without saving. Nothing is stored.
- The user deletes a pin (via the popup card or sidebar). A DELETE /api/annotations/:id call removes it permanently.
- The user marks a pin as “resolved”. The pin remains in the database but changes color (amber to green). It can be reopened or deleted later.
Scoped visibility:
- Pins are scoped to the page path they were created on. A pin placed on /products/123 will never appear on /products/456 or /dashboard.
- The widget fetches annotations using GET /api/annotations/page?path= — only annotations matching the exact current path are returned.
- Regular users see only their own pins. Admins see all pins on the current page.
- The sidebar list shows all of a user’s pins across all pages (or all pins for admins), but the on-page pin rendering is always filtered by current path.
React Query cache invalidation:
After any mutation (create, update status, delete), invalidate the annotation queries so the UI stays in sync:
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data) => fetch(“/api/annotations”, { method: “POST”, body: JSON.stringify(data) }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [“annotations”] });
},
});
const deleteMutation = useMutation({
mutationFn: (id) => fetch(/api/annotations/${id}, { method: “DELETE” }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [“annotations”] });
},
});
7. Page Path Detection
Standard routing (React Router, Wouter, etc.)
import { useSyncExternalStore } from “react”;
function getPathname(): string {
return window.location.pathname;
}
function subscribeToPathname(callback: () => void) {
window.addEventListener(“popstate”, callback);
return () => window.removeEventListener(“popstate”, callback);
}
function useCurrentPagePath(): string {
return useSyncExternalStore(subscribeToPathname, getPathname, getPathname);
}
Hash-based routing (/#/path)
import { useSyncExternalStore } from “react”;
function getHashPath(): string {
const hash = window.location.hash;
if (!hash || hash === “#” || hash === “#/”) return “/”;
const path = hash.startsWith(“#/”) ? hash.slice(1) : hash.startsWith(“#”) ? “/” + hash.slice(1) : hash;
return path.replace(//+$/, “”) || “/”;
}
function subscribeToHash(callback: () => void) {
window.addEventListener(“hashchange”, callback);
return () => window.removeEventListener(“hashchange”, callback);
}
function useCurrentPagePath(): string {
return useSyncExternalStore(subscribeToHash, getHashPath, getHashPath);
}
8. Viewport Clamping
Pin popups and pending pin cards can overflow the viewport edges. Use a useClampToViewport hook that adjusts the card’s position after mount:
function useClampToViewport(ref: React.RefObject) {
useEffect(() => {
if (!ref.current) return;
requestAnimationFrame(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let tx = 0;
let ty = 0;
if (rect.right > window.innerWidth) tx = window.innerWidth - rect.right - 8;
if (rect.left < 0) tx = -rect.left + 8;
if (rect.bottom > window.innerHeight) ty = window.innerHeight - rect.bottom - 8;
if (rect.top < 0) ty = -rect.top + 8;
if (tx !== 0 || ty !== 0) {
el.style.transform = translate(${tx}px, ${ty}px);
}
});
}, [ref]);
}
9. z-index Layers
| z-index | Element |
|---|---|
| 9998 | Pin overlay (pointer-events: none; individual pins are pointer-events: auto) |
| 9999 | Pin popups and pending pin cards |
| 10000 | Placement mode banner, feedback list sidebar |
| 10001 | FAB button |
| 10002 | Onboarding helper card |
10. CSS Animations
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes glowPulse {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
}
@keyframes buttonGlow {
0%, 100% {
box-shadow: 0 0 16px 6px rgba(253, 224, 71, 0.5),
0 0 32px 12px rgba(253, 224, 71, 0.25);
}
50% {
box-shadow: 0 0 24px 10px rgba(253, 224, 71, 0.7),
0 0 48px 20px rgba(253, 224, 71, 0.35);
}
}
11. Mount the Widget
In your main App.tsx, render the widget inside your app layout — after all routes, but inside your auth and query providers:
import { AnnotationWidget } from “@/components/AnnotationWidget”;
function App() {
return (
{/* your routes */}
);
}
12. Authentication
The widget needs a user object with at minimum: id, name (or email), and role.
If you’re on Replit, use Replit Auth (OpenID Connect with PKCE). It gives every Replit user a login without any custom forms. To distinguish admins, either:
- Set an ADMIN_USER_IDS environment variable with a comma-separated list of user IDs, and check against it in your middleware.
- Or add a role column to your users table that defaults to “user” and can be manually set to “admin”.
The widget checks user?.role === “admin” for admin features (seeing all pins, CSV export). Adapt this check to match however your app determines admin status.
13. Customization Checklist
| Item | What to change |
|---|---|
| Colors | The system uses amber tones (amber-800 primary, amber-500 for open pins, green-500 for resolved). Adjust to match your brand. |
| Onboarding text | The helper card should have example feedback prompts relevant to your product. |
| Session storage key | The HELPER_SEEN_KEY constant controls the pulse animation and helper card. Use a unique name for your app (e.g. “myapp-feedback-helper-seen”). |
| Auth hook | The widget uses const { user } = useAuth(). Point this at your auth provider. |
| Routing | Use the standard or hash-based useCurrentPagePath hook depending on your router (see Section 7). |
| API base URL | If your API is not at /api, update the fetch URLs in the widget. |
14. Files to Create/Modify
| File | Action |
|---|---|
| shared/schema.ts (or your schema file) | Add siteAnnotations table, insert schema, types |
| server/storage.ts (or your storage layer) | Add 7 annotation methods to interface + implementation |
| server/routes/annotations.ts | Create new file with 6 endpoints |
| server/routes.ts | Register annotation routes |
| client/src/components/AnnotationWidget.tsx | Create new file (~650 lines) with the full widget |
| client/src/App.tsx | Import and mount |