Frameworks
Next.js, React, Vue, Astro, SvelteKit and a plain script tag — plus the one call every client-side router has to make.
HellDots is framework-agnostic: it is a function that mounts a widget and returns an object. What differs between frameworks is only where you call it and when you tell it the page changed.
Server-rendered apps
Importing the package on the server is safe — nothing touches the DOM at import
time. Calling createCommentOverlay on the server is not. Keep the call on the
client:
'use client';
import { useEffect } from 'react';
import { createCommentOverlay } from 'helldots';
export function Comments({ user }: { user: { name: string; id?: string } }) {
useEffect(() => {
const overlay = createCommentOverlay({ user, persistence: 'localStorage' });
return () => overlay.cleanup();
}, [user]);
return null;
}Render it from the root layout, and see SPAs below for the router wiring — the App Router is one.
cleanup() is what makes this safe
React runs effects twice in development. Returning overlay.cleanup() from
the effect removes the widget entirely, so the second mount does not leave you
with two toolbars. It is also what you want on logout, or when the component
unmounts for any other reason.
Single-page apps
A client-side router swaps the DOM without a page load. HellDots cannot see that happen, so two things have to be wired.
const overlay = createCommentOverlay({
user,
persistence: 'localStorage',
// 1. Let the widget's own cross-page jumps use your router.
navigate: (page) => router.push(page),
});
// 2. Tell the widget after every route render.
router.afterEach(() => overlay.notifyNavigation());navigate is used by the inbox's "view on its page" jump. Without it, that
jump is a full page load, which throws away your app's state.
notifyNavigation() reclassifies every comment against the new pathname,
re-resolves anchors against the new DOM, rebuilds the markers, and moves the
inbox onto the new page. It returns the same { anchored, orphaned, inactive }
counts loadComments() does.
Call it after the new route has actually painted, not before — the elements have to exist for anchors to resolve against them.
Do not schedule it on requestAnimationFrame alone
Two requestAnimationFrames is the natural way to say "after the browser has
laid this out", and it is right — until the tab is hidden. A hidden tab
never paints, so its requestAnimationFrame never fires at all, and a
navigation that happens while somebody is looking at another tab leaves the
widget pointing at the previous page.
Layout is still computed in a hidden tab, so give the frame a deadline rather
than a longer wait — whichever arrives first wins. There is a
worked onNextPaint helper on the
playground page; this site uses it.
Back and forward
createCommentOverlay({ autoDetectNavigation: true });Opt-in, and popstate-only: it covers the browser's back and forward buttons.
pushState routing fires no popstate, so your router's own hook is still
required. Wire both.
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { createCommentOverlay, type CommentOverlay } from 'helldots';
export function Comments() {
const [overlay, setOverlay] = useState<CommentOverlay | null>(null);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
const instance = createCommentOverlay({
persistence: 'localStorage',
autoDetectNavigation: true,
navigate: (page) => router.push(page),
onReady: setOverlay,
});
return () => instance.cleanup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!overlay) return;
// Two frames: let the new route paint before resolving anchors against it.
const frame = requestAnimationFrame(() =>
requestAnimationFrame(() => overlay.notifyNavigation()),
);
return () => cancelAnimationFrame(frame);
}, [overlay, pathname]);
return null;
}Re-anchoring without navigating
notifyNavigation() is also the "re-anchor now" primitive. If your app replaced
a route's DOM without changing the URL — a tab switch, a data refresh that
rebuilds a list — call it and the comments find their elements again.
await refetchDashboard();
overlay.notifyNavigation();Hotkeys and the Shadow DOM
The widget renders inside a Shadow DOM, which is what keeps your CSS out of it.
It has one consequence worth knowing before it bites you: an event that
crosses a shadow boundary is retargeted. A keydown listener on window sees
event.target as the <helldots-root> host element, not the textarea the
character is going into.
Every page-level hotkey that guards itself with "am I typing?" is written
against event.target, so that guard is blind here. On a docs site with a
single-letter theme shortcut, typing d inside a comment flips the page from
light to dark mid-sentence.
This site hit exactly that
Three shortcuts misfired while typing a comment: d and D toggled the
theme (the host's guard was defeated by retargeting), Cmd/Ctrl+K opened
the search dialog over an unsent draft, and Alt+C — HellDots' own
shortcut, and the way you type ç on macOS — closed the box.
composedPath() is the fix. Unlike target it reports the real element inside
the shadow root, so a guard written against it sees the textarea:
function isEditable(node: EventTarget): boolean {
if (!(node instanceof HTMLElement)) return false;
if (node.isContentEditable) return true;
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName);
}
function typing(event: KeyboardEvent): boolean {
return event.composedPath().some(isEditable);
}If the hotkey is yours, guard it with that. If it belongs to a framework you do
not control, intercept it first — a capture-phase listener on window runs
before any of them:
window.addEventListener(
'keydown',
(event) => {
if (event.isComposing || !typing(event)) return;
const key = event.key.toLowerCase();
const bare = !event.metaKey && !event.ctrlKey && !event.altKey;
// Propagation only — never preventDefault, or the character is not typed.
if ((key === 'd' && bare) || (event.altKey && event.code === 'KeyC')) {
event.stopImmediatePropagation();
}
},
true,
);Two details that matter:
- Stop propagation, never
preventDefault(). Propagation control keeps the hotkey handler from running; the default action — inserting the character — is unaffected either way. CallingpreventDefault()would swallow the keystroke. - Do not blanket-block. Leave
Escape,EnterandCmd+Enteralone: the widget uses them to dismiss the box, send a reply and send a comment.
HellDots' own shortcut is unguarded too
Alt+C fires from inside a text field, including the widget's own comment
box, and e.code === "KeyC" means it catches the macOS Option+C that
produces ç. Until that is guarded in the library, the interception above
is what covers it. Changing
shortcutModifier to "ctrl" avoids the ç
collision specifically.
No bundler
The UMD build defines a HellDots global and carries the screenshot renderer
inside it:
<script src="https://unpkg.com/helldots@0.12.1"></script>
<script>
const overlay = HellDots.createCommentOverlay({
user: { name: 'Ana' },
persistence: 'localStorage',
});
</script>unpkg serves the UMD build by default
The package's unpkg field points at dist/helldots.umd.js, so
https://unpkg.com/helldots — with or without ?module — is the UMD file. It
assigns a global and exports nothing, so importing that URL will not work.
For native ESM with no build step, use a CDN that resolves bare specifiers, so
the dynamic import("modern-screenshot") inside the package has somewhere to
go:
<script type="module">
import { createCommentOverlay } from 'https://esm.sh/helldots@0.12.1';
createCommentOverlay({ persistence: 'localStorage' });
</script>Serving dist/helldots.esm.js directly from unpkg or jsDelivr works too, but
then the peer dependency is yours to resolve — with an import map:
<script type="importmap">
{
"imports": {
"modern-screenshot": "https://esm.sh/modern-screenshot@4"
}
}
</script>
<script type="module">
import { createCommentOverlay } from 'https://unpkg.com/helldots@0.12.1/dist/helldots.esm.js';
createCommentOverlay({ persistence: 'localStorage' });
</script>Deferring the mount
By default createCommentOverlay mounts immediately. Pass autoInit: false and
you get an initializer back instead — useful when the widget should appear only
for signed-in staff, or behind a feature flag.
const init = createCommentOverlay({ autoInit: false, user });
if (session.isStaff) {
const overlay = init();
}