HellDots

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:

components/comments.tsx
'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. Calling preventDefault() would swallow the keystroke.
  • Do not blanket-block. Leave Escape, Enter and Cmd+Enter alone: 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();
}

On this page