HellDots

Playground

HellDots is running on this site. Leave a comment on these docs, and drive the same widget through its public API.

Everything on this page is the real library. The toolbar at the bottom of your screen belongs to a CommentOverlay mounted in this site's root layout, with persistence: "localStorage" — so whatever you leave here is stored in your browser only and will still be here tomorrow. Nothing is sent anywhere; there is no server on the other end of it.

Mounting…persistence: localStorage
0
Total
0
Open
0
In progress
0
Resolved

These figures come from overlay.getMetrics(), re-read on every change the widget emits. Comments live in this browser only — nothing is sent anywhere.

Try it

Turn comment mode on

Press Alt+C (Option+C on macOS), click the toolbar's dot button, or use the button above — all three flip the same switch.

Click something

Anything on this page. A heading, a link, a button in the mock UI below. The comment box opens immediately and the screenshot renders behind it.

Drag instead

Drag a box around a region and HellDots attaches a full-resolution crop of exactly what you selected, on top of the automatic viewport capture.

Come back later

Reload. Navigate to another page in these docs and back. The comment re-anchors itself to the element it was left on — that is the whole point of the anchor fingerprint.

Something to comment on

Demo surface

Pick a plan

Not a real product — a page to leave comments on. Try dragging a box around the Pro price.

Starter

$0/mo

For one person poking at an idea.

  • 1 project
  • Local comments
  • Community support

Pro

Popular

$12/mo

For a team that reviews together.

  • Unlimited projects
  • Shared inbox
  • CSV & PDF export
  • Priority support

Team

$29/mo

For everyone who touches the product.

  • SSO
  • Audit trail retention
  • Roles & permissions

Leave a comment on the Upgrade to Pro button, then open the inbox and look at the detail: the selector, the DOM path, the nearby text, the viewport it was reported at. That block is also what the copy button puts on your clipboard, formatted for pasting into a coding agent.

How this site does it

No private hooks — the docs use the same public API everything else does. The whole integration is one client component in the root layout:

components/helldots/provider.tsx
'use client';

import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import type { CommentOverlay } from 'helldots';

export function HellDotsProvider({ children }) {
  const [overlay, setOverlay] = useState<CommentOverlay | null>(null);
  const router = useRouter();
  const pathname = usePathname();

  useEffect(() => {
    let cancelled = false;
    let instance: CommentOverlay | undefined;

    void import('helldots').then(({ createCommentOverlay }) => {
      if (cancelled) return;

      instance = createCommentOverlay({
        user: readIdentity(), // a name in localStorage + a minted anon id
        persistence: 'localStorage',
        fastCapture: true,
        navigate: (page) => router.push(page),
        autoDetectNavigation: true,
        onReady: setOverlay,
        onCommentModeChanged: setCommentMode,
      });
    });

    return () => {
      cancelled = true;
      instance?.cleanup();
    };
  }, []);

  // pushState routing fires no popstate — tell the widget once the new route
  // has painted and its elements exist to anchor against.
  useEffect(() => {
    if (!overlay) return;
    return onNextPaint(() => overlay.notifyNavigation());
  }, [overlay, pathname]);

  return children;
}

Five decisions in there are worth naming, because they are the ones any App Router site has to make:

  • import() inside the effect. The package is safe to import on the server, but there is no reason to ship it in the first payload of a documentation site. This keeps it out until the page is interactive.
  • cancelled + cleanup(). React's development double-mount runs the effect twice. The flag stops the first import from mounting a second toolbar after its cleanup has already run.
  • navigate: router.push. The inbox's "view on its page" jump would otherwise be a full page load, throwing away the whole SPA.
  • onCommentModeChanged. The keyboard shortcut never reaches React, and the mode switches itself off after a comment is saved — without this the panel's button drifts out of step with the toolbar.
  • notifyNavigation() on pathname. autoDetectNavigation covers back and forward; pushState routing does not fire popstate, so the router's own change is what triggers the re-anchor.
  • A capture-phase hotkey shield. The widget lives in a Shadow DOM, so this page's own shortcuts could not tell that you were typing inside it — d flipped the theme mid-comment. See hotkeys and the Shadow DOM.

That last one has a wrinkle worth stealing. The obvious scheduling is two requestAnimationFrames — the first lands after React commits, the second after the browser lays the commit out. But a hidden tab never paints, so its requestAnimationFrame never fires at all, and a navigation that happens while you are looking at another tab would leave the widget pointing at the previous page. Layout is still computed in a hidden tab, so the fix is a deadline, not a longer wait:

function onNextPaint(run: () => void): () => void {
  let done = false;
  let inner = 0;

  const fire = () => {
    if (done) return;
    done = true;
    run();
  };

  const outer = requestAnimationFrame(() => {
    inner = requestAnimationFrame(fire);
  });
  const timer = window.setTimeout(fire, 150);

  return () => {
    done = true;
    cancelAnimationFrame(outer);
    cancelAnimationFrame(inner);
    clearTimeout(timer);
  };
}

The identity is minted the way the README suggests for an app with no accounts: a crypto.randomUUID() kept in localStorage under a key this site owns. It identifies a browser profile, not a person.

const KEY = 'helldots-docs:anon-id';
let id = localStorage.getItem(KEY);
if (!id) localStorage.setItem(KEY, (id = crypto.randomUUID()));

Comments and the language switch

Worth knowing before it surprises you: a comment does not follow you across languages. HellDots keys a comment to location.pathname, and the two translations of a page are two URLs — /docs/guides/captures and /es/docs/guides/captures.

So a comment left on the English page reads as inactive on the Spanish one: not lost, still in the corpus, still listed in the inbox — just not anchored to anything on the page you are looking at. Switch back and it is anchored again.

That is the library behaving correctly rather than a gap in it. A site that wanted one shared thread per page regardless of language would strip the locale prefix before handing the record to its own store, which is a decision only the host can make.

Drive it from your console

This site puts the live instance on window — so the fastest way to read the API reference is to open your console and try it.

helldots.comments.length;
helldots.toggleCommentMode();

const [first] = helldots.serializeComments();
helldots.setCommentType(first.id, 'bug');
helldots.setCommentPriority(first.id, 'high');
helldots.toggleCommentReaction(first.id, '🚀');

helldots.getMetrics();
helldots.commentLink(first.id);

That handle is this site's doing, not the library's — one line in onReady. HellDots itself puts nothing on window.

Clearing up

Your comments live under a single localStorage key on this origin. Clear all in the panel above calls overlay.clearComments(), which removes the markers, the memory and the stored copy in one go. Clearing site data in your browser does the same thing more bluntly.

This is a demo, not a notebook

These docs get republished. A localStorage corpus survives that fine, but do not keep anything here you would mind losing to a cleared browser profile or a different device — localStorage is per-origin, per-browser, and goes no further. For real work, wire the callbacks to a backend.

On this page