blocfeed
v0.8.1
Published
Drop-in feedback widget for React — element picking, screenshots, and video recording.
Maintainers
Readme
BlocFeed (blocfeed)
Drop-in in-app feedback widget for Next.js and React:
- Safe "feedback mode" with DOM element picking (blocks host app clicks while active)
- Optional element + full page screenshots (capture code is lazy-loaded)
- Typed, JSON-serializable
FeedbackPayloadwith contextual metadata - Submits directly to the BlocFeed platform via
blocfeed_id - Optional
selection.componentName+selection.testIdfor faster triage - First-class user identity — attach
user.id,email,nameto every submission - Offline queue — failed submissions are stored and retried automatically
- Customizable position, theme, and retry behavior
- Accessible — keyboard navigation, focus trapping, ARIA attributes
- Feedback categories — pill selector (Bug, Feature, UX, General) included in payload
- Dark / Light mode —
"dark","light", or"auto"(follows system preference) - Conditional display — show/hide widget by route pattern or custom predicate
- Programmatic API —
ref.open(),ref.close(),ref.submit(msg)via React ref - Video recording — record a short screen capture clip (via
getDisplayMedia) to show bug reproduction steps - Secret leak detection — scans client-side code for exposed API keys, database credentials, and tokens
Docs live in docs/ (start at docs/index.md). Architecture pointers are in ARCHITECTURE.md.
Install
npm install blocfeedPeer deps: react, react-dom.
Get your blocfeed_id
Create a project in the BlocFeed dashboard (https://blocfeed.com). Each project has a unique blocfeed_id that the SDK uses to link incoming feedback to the correct project.
Submission endpoint
Submissions are sent to the BlocFeed platform ingestion API at https://blocfeed.com/api/feedback. Custom/external endpoints are intentionally not supported.
Next.js (App Router) — copy/paste
app/layout.tsx
import type { ReactNode } from "react";
import { BlocFeedWidget } from "blocfeed";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{children}
<BlocFeedWidget blocfeed_id="bf_your_project_blocfeed_id" />
</body>
</html>
);
}Next.js (Pages Router)
Use a client-only dynamic import:
import dynamic from "next/dynamic";
const BlocFeedWidget = dynamic(
() => import("blocfeed").then((m) => m.BlocFeedWidget),
{ ssr: false }
);
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<BlocFeedWidget blocfeed_id="bf_your_project_blocfeed_id" />
</>
);
}React (SPA)
import { BlocFeedWidget } from "blocfeed";
export function App() {
return (
<>
{/* your app */}
<BlocFeedWidget blocfeed_id="bf_your_project_blocfeed_id" />
</>
);
}Configuration
All configuration is passed via the config prop on <BlocFeedWidget> or <BlocFeedProvider>:
<BlocFeedWidget
blocfeed_id="bf_..."
config={{
// User identity (attached to every submission)
user: {
id: "user_123",
email: "[email protected]",
name: "Jane Doe",
},
// Widget position, theme & trigger style
ui: {
position: "bottom-right", // "bottom-left" | "top-right" | "top-left"
triggerStyle: "dot", // "classic" | "bubble" | "edge-tab" | "pulse-ring" | "minimal" | "icon-pop" | "beacon" | "typewriter"
triggerLabel: "Feedback", // custom label text
shortcut: "mod+shift+f", // keyboard shortcut to toggle widget
zIndex: 99999,
theme: {
accentColor: "#12D393",
panelBackground: "rgba(0, 0, 0, 0.95)",
panelForeground: "#ffffff",
fontFamily: "Inter, sans-serif",
mode: "dark", // "dark" | "light" | "auto"
},
categories: ["bug", "feature", "ux", "general"], // feedback category pills
showOn: ["/dashboard/*", "/app/*"], // route-based visibility
},
// Diagnostics (console + network capture, including XHR)
diagnostics: {
console: true,
network: true, // captures both fetch and XMLHttpRequest
// ignoreUrls: [], // override default blocked domains (see docs below)
},
// Video recording (screen capture)
recording: {
enabled: true,
maxDurationMs: 30_000,
videoBitsPerSecond: 2_500_000,
},
// Screenshot defaults
capture: {
element: true,
fullPage: false,
mime: "image/png", // or "image/jpeg"
quality: 0.92, // JPEG quality (0–1)
timeoutMs: 12000,
maxDimension: 2048,
pixelRatio: 2,
},
// Retry & transport
transport: {
timeoutMs: 12000, // per-request timeout
maxAttempts: 2, // retry count
backoffMs: 500, // base backoff delay
},
// Security — secret leak detection
security: {
secretScan: true,
scanTargets: ["hydration", "scripts", "meta", "dom"],
},
// Metadata enrichment
metadata: {
enabled: true,
enrich: async (context) => ({
orgId: "org_abc",
plan: "pro",
}),
},
// Picker rules
picker: {
ignoreSelectors: ["[data-private]"],
isSelectable: (el) => el.tagName !== "HTML",
},
}}
/>User Identity
Attach user information to every feedback submission:
<BlocFeedWidget
blocfeed_id="bf_..."
config={{
user: {
id: currentUser.id,
email: currentUser.email,
name: currentUser.name,
},
}}
/>The user object is included in the payload as a top-level field and its values are also merged into metadata as userId, userEmail, userName.
Widget Position
Place the trigger button in any corner:
config={{
ui: { position: "bottom-left" } // default: "bottom-right"
}}Options: "bottom-right" | "bottom-left" | "top-right" | "top-left"
Trigger Styles
Customize the trigger button's appearance and animation:
config={{
ui: { triggerStyle: "dot" }
}}| Style | Description | Requires framer-motion |
|-------|-------------|----------------------|
| "classic" | Default pill button with colored dot (no animation) | No |
| "dot" | Breathing dot that expands to pill on hover | Yes |
| "bubble" | Floating chat-bubble icon with tooltip on hover | Yes |
| "edge-tab" | Thin tab anchored to screen edge, slides out on hover | Yes |
| "pulse-ring" | Sonar-style pulsing rings around a dot, expands on hover | Yes |
| "minimal" | Text-only "Feedback" with animated underline on hover | Yes |
| "icon-pop" | Wobbling icon that pops and reveals text on hover | Yes |
| "beacon" | Lighthouse-style sweeping beam with glowing dot, expands on hover | Yes |
| "typewriter" | Text types out character by character with blinking cursor | Yes |
All animated styles include smooth enter/exit transitions. Animations automatically simplify when prefers-reduced-motion: reduce is active.
Custom trigger label
config={{
ui: { triggerLabel: "Report Bug" }
}}The label defaults to "Feedback" and is used across all trigger styles.
Keyboard shortcut
Open/close the widget with a keyboard shortcut:
config={{
ui: { shortcut: "mod+shift+f" }
}}Use mod for platform-aware behavior (Ctrl on Windows/Linux, Cmd on Mac). Supports ctrl, meta/cmd, shift, alt/option modifiers.
Installing framer-motion
All styles except "classic" require framer-motion as a peer dependency:
npm install framer-motioni18n / Localization
Override all UI text via config.ui.strings:
config={{
ui: {
strings: {
triggerLabel: "Rückmeldung",
panelTitle: "Rückmeldung",
hintText: "Klicken Sie auf ein Element. Drücken Sie Esc zum Abbrechen.",
cancelButton: "Abbrechen",
rePickButton: "Neu wählen",
closeButton: "Schließen",
textareaPlaceholder: "Was ist passiert? Was haben Sie erwartet?",
screenshotElement: "Element-Screenshot",
screenshotFullPage: "Ganze Seite",
capturingText: "Screenshots werden aufgenommen…",
submittingText: "Wird gesendet…",
successText: "Gesendet. Vielen Dank!",
toastText: "Feedback gesendet",
sendButton: "Senden",
},
},
}}All fields are optional — only override the ones you need. Category labels are also localizable:
strings: {
categoryBug: "Fehler",
categoryFeature: "Funktion",
categoryUx: "UX",
categoryGeneral: "Allgemein",
}Feedback Categories
Show a pill selector in the feedback form so users can tag their feedback:
config={{
ui: {
categories: ["bug", "feature", "ux", "general"],
},
}}The selected category is included in the payload as category. All four categories are shown by default. To show only a subset:
config={{
ui: {
categories: ["bug", "feature"], // only Bug and Feature pills
},
}}Set categories: [] to hide the pill selector entirely.
Conditional Display (showOn)
Restrict widget visibility to specific routes. By default the widget shows on all pages.
Route patterns
config={{
ui: {
showOn: ["/dashboard/*", "/app/*", "/settings"],
},
}}Patterns support exact matching or wildcard * suffix (matches any path starting with the prefix).
Custom predicate
config={{
ui: {
showOn: (pathname) => !pathname.startsWith("/admin"),
},
}}The widget automatically detects SPA navigation (pushState, replaceState, popstate) and re-evaluates on every route change.
Programmatic API
Control the widget programmatically via a React ref:
import { useRef } from "react";
import { BlocFeedWidget, type BlocFeedHandle } from "blocfeed";
function App() {
const feedbackRef = useRef<BlocFeedHandle>(null);
return (
<>
<button onClick={() => feedbackRef.current?.open()}>
Report a Bug
</button>
<BlocFeedWidget
ref={feedbackRef}
blocfeed_id="bf_..."
/>
</>
);
}BlocFeedHandle methods
| Method | Description |
|--------|-------------|
| open() | Open the widget (starts element picking) |
| close() | Close the widget and reset state |
| submit(message) | Submit feedback programmatically (returns Promise<SubmitResult>) |
| isOpen | boolean — whether the widget is currently active |
Console & Network Diagnostics
Automatically capture console errors and failed network requests to include with every feedback submission:
config={{
diagnostics: {
console: true, // capture console.error & console.warn
consoleLevels: ["error", "warn"], // which levels to capture
consoleLimit: 20, // max entries retained
network: true, // capture failed fetch & XHR requests
networkLimit: 15, // max entries retained
},
}}Captured data is attached to metadata._consoleLogs and metadata._networkErrors in the payload. Messages and stacks are truncated at 2KB.
Both fetch and XMLHttpRequest are intercepted, so apps using axios, jQuery, or other XHR-based libraries get full network error capture.
Network URL filtering (ignoreUrls)
By default, BlocFeed automatically filters out requests to analytics, tracking, and third-party service domains to keep diagnostics focused on relevant errors. The built-in blocklist includes 50+ domains:
| Category | Blocked domains |
|----------|----------------|
| BlocFeed | blocfeed.com |
| Google Analytics / Ads | google-analytics.com, googletagmanager.com, analytics.google.com, googleads.g.doubleclick.net, googlesyndication.com, googleadservices.com, google.com/pagead, google.com/ads |
| Facebook / Meta | facebook.net, facebook.com/tr, connect.facebook.net, graph.facebook.com |
| Mixpanel | api.mixpanel.com, api-js.mixpanel.com |
| Amplitude | api.amplitude.com, api2.amplitude.com |
| Segment | api.segment.io, cdn.segment.com |
| Hotjar | vars.hotjar.com, in.hotjar.com, script.hotjar.com |
| Heap | heapanalytics.com |
| FullStory | fullstory.com/s/fs.js, rs.fullstory.com |
| PostHog | app.posthog.com, us.posthog.com, eu.posthog.com |
| Intercom | api-iam.intercom.io, widget.intercom.io |
| Sentry | sentry.io/api, browser.sentry-cdn.com |
| Datadog | browser-intake-datadoghq.com |
| Microsoft Clarity | clarity.ms |
| HubSpot | js.hs-analytics.net, api.hubapi.com, forms.hubspot.com |
| Plausible | plausible.io/api |
| Crisp | client.crisp.chat |
| Drift | js.driftt.com |
| LogRocket | r.logrocket.com |
| Pendo | app.pendo.io |
| LaunchDarkly | events.launchdarkly.com, app.launchdarkly.com |
| Grammarly | grammarly.com, gnar.grammarly.com, capi.grammarly.com |
| LanguageTool | api.languagetool.org, api.languagetoolplus.com |
| Google Fonts | fonts.googleapis.com, fonts.gstatic.com |
| Cookie consent | cdn.cookielaw.org, consent.cookiebot.com |
| Stripe.js | js.stripe.com, api.stripe.com/v1/tokens |
| CAPTCHA | google.com/recaptcha, hcaptcha.com |
| New Relic | bam.nr-data.net |
| Smartlook | rec.smartlook.com |
| Mouseflow | o2.mouseflow.com |
| Lucky Orange | api.luckyorange.com |
| Zendesk | static.zdassets.com, ekr.zdassets.com |
Each pattern is matched as a substring against the request URL (case-insensitive).
Add extra domains
Pass additional patterns alongside the built-in blocklist:
config={{
diagnostics: {
network: true,
ignoreUrls: ["my-internal-analytics.com", "custom-tracker.io"],
},
}}Note: When you provide
ignoreUrls, it replaces the built-in blocklist entirely. To keep the defaults and add your own patterns, you would need to include them manually — or simply omitignoreUrlsto use the built-in list as-is.
Capture everything (disable blocklist)
config={{
diagnostics: {
network: true,
ignoreUrls: [], // empty array disables all filtering
},
}}Headless diagnostics
import {
installDiagnostics,
uninstallDiagnostics,
drainDiagnostics,
clearDiagnostics,
} from "blocfeed/engine";Security — Client-Side Secret Leak Detection
Detect accidentally exposed API keys, database credentials, and tokens in client-side code:
<BlocFeedWidget
blocfeed_id="bf_..."
config={{
security: {
secretScan: true, // enable scanning (default when security config is present)
scanTargets: ["hydration", "scripts", "meta", "dom"], // what to scan
},
}}
/>When secrets are detected, a dismissible warning banner appears in the widget, and findings are attached as metadata._securityFindings in every feedback submission. Secret values are never transmitted — only the first 6 characters + ***.
What gets scanned
| Target | What | Why |
|--------|------|-----|
| hydration | __NEXT_DATA__, __NUXT__ | Server-side props leaked to client |
| scripts | Inline <script> tags | Hardcoded secrets in JavaScript |
| meta | <meta> tag content attributes | Tokens in meta tags |
| dom | data-api-key, data-secret, etc. | Keys in HTML attributes |
Built-in patterns (20+)
- Supabase / Postgres:
SUPABASE_SERVICE_ROLE_KEY,POSTGRES_PASSWORD,DATABASE_URLwith embedded credentials - AWS: Access keys (
AKIA...), secret keys, session tokens - Stripe: Secret keys (
sk_live_...,sk_test_...), restricted keys - GitHub: PATs (
ghp_), OAuth tokens (gho_), fine-grained tokens (github_pat_) - OpenAI / Anthropic: API keys
- Infrastructure: Private keys, database URLs with credentials, SendGrid, Twilio
- Generic: Any env var ending in
_SECRET,_PASSWORD,_PRIVATE_KEY
Note: Known-public keys like
SUPABASE_ANON_KEYandSUPABASE_URLare intentionally not flagged.
Custom patterns
Add app-specific secret formats:
config={{
security: {
customPatterns: [
{ name: "internal_api_key", pattern: /myapp_sk_[a-zA-Z0-9]{32}/ },
],
},
}}Headless secret scanning
import {
runSecretScan,
getSecurityFindings,
clearSecurityFindings,
} from "blocfeed/engine";
runSecretScan({ secretScan: true });
// ... after scan completes (~100ms)
const snapshot = getSecurityFindings();
console.log(snapshot.findings);Theme Customization
Override the widget's visual appearance via CSS variables:
config={{
ui: {
theme: {
accentColor: "#12D393", // buttons, highlights, focus rings
panelBackground: "rgba(0,0,0,0.95)", // panel & trigger background
panelForeground: "#ffffff", // text color
fontFamily: "Inter, sans-serif", // font stack
mode: "auto", // "dark" | "light" | "auto"
},
},
}}Dark / Light Mode
The widget defaults to dark mode. Set theme.mode to change the color scheme:
| Mode | Behavior |
|------|----------|
| "dark" | Dark panel, light text (default) |
| "light" | Light panel, dark text |
| "auto" | Follows the user's system preference (prefers-color-scheme) and updates live |
Explicit panelBackground / panelForeground overrides take precedence over the mode defaults.
Retry & Transport
Configure retry behavior for unreliable networks:
config={{
transport: {
timeoutMs: 15000, // 15s timeout per attempt
maxAttempts: 3, // retry up to 3 times
backoffMs: 1000, // 1s base backoff (exponential with jitter)
},
}}Offline Queue
When a submission fails due to a network error, the payload is automatically saved to localStorage and retried:
- On the next page load (1s after controller initialization)
- When the browser comes back online (
onlineevent)
Screenshots and video recordings are stripped from queued payloads to stay within localStorage limits. The queue holds up to 50 items (FIFO eviction).
Picking rules (ignore / filter)
Ignore a subtree (recommended)
<div data-blocfeed-ignore="true">Sensitive UI</div>Ignore selectors / filter by predicate
picker: {
ignoreSelectors: ["[data-private]", ".no-feedback"],
isSelectable: (el) => el.tagName !== "HTML" && el.tagName !== "BODY"
}Component name + test id in payload (triage)
selection.componentName
React component names are not available from the DOM by default. For a stable name, tag a component root:
export function CheckoutButton() {
return (
<button data-blocfeed-component="CheckoutButton" type="button">
Checkout
</button>
);
}BlocFeed will also attempt a best-effort component name in React/Next dev builds (no setup), but it may be missing/minified in production. In production, the fiber traversal is limited to 10 nodes (vs 80 in dev) to avoid wasting cycles on minified names.
selection.testId
BlocFeed extracts a best-effort testId from common attributes like data-testid, data-cy, etc.
Screenshots
Screenshots are uploaded efficiently using a two-phase flow:
- The text payload (metadata, selection, message) is sent first without screenshot data
- If the platform returns presigned upload URLs (Wasabi/S3), screenshots are uploaded directly to object storage
- If presigned URLs are unavailable, screenshots fall back to a secondary POST endpoint
Both element and full-page screenshots are supported and stored on the platform.
Video Recording
Let users record a short screen capture clip alongside their feedback. Uses the browser's Screen Capture API (getDisplayMedia) to record the current tab as WebM video.
<BlocFeedWidget
blocfeed_id="bf_..."
config={{
recording: {
enabled: true, // default: false
maxDurationMs: 30_000, // default: 30s — auto-stops at this limit
videoBitsPerSecond: 2_500_000, // default: 2.5 Mbps (~9 MB for 30s)
},
}}
/>RecordingConfig options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | false | Show the Record button in the review phase |
| maxDurationMs | number | 30000 | Maximum recording duration in ms. Auto-stops when reached |
| mime | "video/webm" | "video/webm" | Output format (WebM is the only supported format) |
| videoBitsPerSecond | number | 2500000 | Video bitrate. Lower = smaller files, faster uploads |
Bitrate & file size guide
| videoBitsPerSecond | 30s file size | Upload time (10 Mbps) |
|---|---|---|
| 1_000_000 (1 Mbps) | ~3.75 MB | ~3s |
| 2_500_000 (2.5 Mbps) | ~9.4 MB | ~7s |
| 5_000_000 (5 Mbps) | ~18.8 MB | ~15s |
Lower bitrates are fine for bug reproduction clips. 2.5 Mbps (default) gives crisp screen recordings.
How it works
- A Record button appears in the review phase (after element selection)
- Clicking it triggers the browser's screen sharing permission prompt
- A pulsing red dot + elapsed timer shows during recording
- Recording stops when the user clicks Stop, the max duration is reached, or the browser's "Stop sharing" button is clicked
- A video preview with playback controls appears — the user can remove it or submit
- On submit, the video is uploaded directly to object storage via a presigned URL
Browser support
Video recording requires getDisplayMedia and MediaRecorder with WebM support. The Record button is automatically hidden on unsupported browsers.
| Browser | Supported | |---------|-----------| | Chrome / Edge | Yes | | Firefox | Yes | | Safari 16+ | No (WebM not supported by MediaRecorder) |
Programmatic API
const ref = useRef<BlocFeedHandle>(null);
ref.current?.startRecording(); // start recording (must be in review phase)
ref.current?.stopRecording(); // stop recordingLocalization
Override recording-related UI text via config.ui.strings:
strings: {
recordButton: "Record",
stopRecordButton: "Stop",
recordingText: "Recording…",
recordingDeniedText: "Screen recording permission was denied.",
videoPreviewLabel: "Video preview",
removeVideoButton: "Remove video",
}Known limitations
The default screenshot engine (html-to-image) has known issues with:
- Cross-origin images without permissive CORS headers
- CSS
clip-pathandbackdrop-filter - Some web fonts
Alternative screenshot adapter
For better compatibility, you can use modern-screenshot as a drop-in replacement:
npm install modern-screenshotimport { createModernScreenshotAdapter } from "blocfeed/engine";
import * as modernScreenshot from "modern-screenshot";
<BlocFeedWidget
blocfeed_id="bf_..."
config={{
capture: {
adapter: createModernScreenshotAdapter(modernScreenshot),
},
}}
/>Convert to Blob
import { dataUrlToBlob } from "blocfeed/engine";
const blob = dataUrlToBlob(payload.screenshots?.element?.dataUrl ?? "");Keyboard Accessibility
The widget supports full keyboard navigation:
- Tab cycles through interactive elements in the feedback panel
- Shift+Tab cycles backward
- Escape cancels picking or closes the panel
- Ctrl/Cmd+Enter submits feedback from the textarea
- Custom shortcut — configurable via
config.ui.shortcut(e.g.,"mod+shift+f") - Focus is trapped within the panel when open
- All interactive elements have
aria-labelattributes - Status messages use
aria-live="polite"for screen reader announcements - Animations respect
prefers-reduced-motion: reduce— all motion is disabled when the user prefers reduced motion
Client-Side Rate Limiting
A minimum 2-second interval is enforced between submissions to prevent accidental spam. Rapid submissions return a descriptive error.
Custom UI
If you don't want the default widget UI:
import { BlocFeedProvider, useBlocFeed } from "blocfeed";
function FeedbackButton() {
const { start } = useBlocFeed();
return <button onClick={start}>Give feedback</button>;
}
export function App() {
return (
<BlocFeedProvider blocfeed_id="bf_your_project_blocfeed_id">
{/* your app */}
<FeedbackButton />
</BlocFeedProvider>
);
}Headless engine
import { createBlocFeedController } from "blocfeed/engine";
const controller = createBlocFeedController({ blocfeed_id: "bf_your_project_blocfeed_id" });Offline queue utilities (headless)
import { enqueue, dequeueAll, clearQueue, getQueueSize } from "blocfeed/engine";TypeScript
All types are exported from both entry points:
import type {
BlocFeedConfig,
BlocFeedHandle,
BlocFeedUser,
FeedbackCategory,
FeedbackPayload,
FeedbackApiResponse,
BlocFeedState,
SessionPhase,
ThemeConfig,
TransportConfig,
TriggerStyle,
WidgetPosition,
RecordingConfig,
VideoAsset,
VideoIntent,
VideoMime,
SecurityConfig,
SecurityFinding,
SecuritySnapshot,
// ... and more
} from "blocfeed";Local development
cd blocfeed
npm test
npm run build
npm run playground:install
npm run playground:devLicense
MIT
