npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

frustration-observer

v1.0.0

Published

Detect rage clicks, hesitation and dead clicks on any DOM element. Framework-agnostic, zero dependencies.

Readme

frustration-observer

npm CI minzipped size license

Detect rage clicks, hesitation and dead clicks on any DOM element. 2.4 KB minified and gzipped, no dependencies, no framework.

Try the live demo

import { createInteractionObserver } from "frustration-observer";

const observer = createInteractionObserver();

observer.on("rageclick", (event) => {
  console.log(`${event.clicks} clicks in ${event.duration}ms`);
});

observer.observe(document.querySelector("#buy")!);

What it detects

| Event | Fires when | | ------------ | ----------------------------------------------------------------------- | | rageclick | Several clicks land on one element, in one spot, inside a short window. | | hesitation | The pointer rests on an element for a while without acting. | | deadclick | A click is followed by nothing changing on the page. |

Why it exists

Analytics tell you that someone abandoned a checkout. They rarely tell you that the person clicked "Complete purchase" seven times first, or hovered over the price for four seconds, or pressed a button that quietly did nothing. Those are the moments worth fixing, and they are all visible from the browser.

This library detects them and hands you an event. What you do with it, whether that is logging, sending it somewhere, or highlighting the element in development, is entirely up to you.

Install

npm install frustration-observer

Or use it straight from a CDN, which exposes a global called InteractionObserver:

<script src="https://unpkg.com/frustration-observer"></script>

Usage

import { createInteractionObserver } from "frustration-observer";

const observer = createInteractionObserver({
  rageClick: { clicks: 3, interval: 1000, radius: 30 },
  hesitation: { threshold: 2000 },
  deadClick: { timeout: 1000, ignore: "[data-live]" },
});

observer.on("rageclick", (event) => report(event));
observer.on("hesitation", (event) => report(event));
observer.on("deadclick", (event) => report(event));

for (const element of document.querySelectorAll("button")) {
  observer.observe(element);
}

Every detector is enabled with sensible defaults, so createInteractionObserver() on its own works. Options merge with the defaults, and any detector can be switched off:

createInteractionObserver({ hesitation: false });

Observing a parent covers everything inside it. A click on an icon within a button is reported as a click on the button.

Configuration

| Option | Type | Default | Meaning | | ---------------------- | -------- | ------- | ------------------------------------------------------------------- | | rageClick.clicks | number | 3 | Clicks required before a burst counts. Whole number, minimum 2. | | rageClick.interval | number | 1000 | Milliseconds the window stays open after each click. | | rageClick.radius | number | 30 | Pixels the clicks may spread from the first one. | | hesitation.threshold | number | 2000 | Milliseconds the pointer must rest before hesitating. | | deadClick.timeout | number | 1000 | Milliseconds to wait for the page to react. | | deadClick.ignore | string | none | CSS selector for elements whose changes do not count as a reaction. |

Unusable values are rejected when the observer is created, not silently ignored: a TypeError for the wrong type, a RangeError for a number that cannot work.

Events

Every event carries type, target (the observed element) and timestamp.

interface RageClickEvent {
  type: "rageclick";
  target: Element;
  timestamp: number;
  clicks: number; // total clicks in the burst
  duration: number; // milliseconds from first to last
  radius: number; // pixels from the first click to the furthest
  position: { x: number; y: number }; // viewport position of the last click
}

interface HesitationEvent {
  type: "hesitation";
  target: Element;
  timestamp: number;
  duration: number; // milliseconds the pointer had rested
}

interface DeadClickEvent {
  type: "deadclick";
  target: Element;
  timestamp: number;
  timeout: number; // milliseconds waited before giving up
  position: { x: number; y: number };
}

A rage click is reported once per burst, one interval after the last click, so the count is the whole burst rather than the moment the threshold was crossed.

Cleanup

const off = observer.on("rageclick", handler); // on() returns an unsubscribe
off();

observer.unobserve(element); // stop watching one element
observer.disconnect(); // stop watching everything

disconnect() removes the library's document listeners, forgets every observed element and cancels anything pending. Listeners registered with on() survive it, so the observer can be used again by observing another element, the same way MutationObserver behaves.

Elements are held by strong reference. Removing an element from the DOM without calling unobserve() keeps it alive, so tear down when a view goes away.

Browser support

Any browser with pointer events and MutationObserver. The build targets ES2020, which means Chrome 80, Edge 80, Firefox 74 and Safari 14 or newer. No polyfills are needed.

Importing the package touches no browser API, so it is safe in server-rendered apps. Listeners are attached on the first observe() call.

Limitations

These come from what a browser can actually see, and are not planned to change.

  • A page that changes constantly hides its dead clicks. A clock, carousel or chat widget counts as the page reacting. The ignore selector covers the common cases.
  • A click whose only effect is outside the DOM looks dead. Writing to the clipboard or playing audio changes nothing observable.
  • Slow responses look dead. A click that starts a two second request with a one second timeout is reported as a dead click.
  • Hesitation needs a hover-capable pointer, so it does not fire on touch screens.

Non-goals

This is a detection primitive, not an analytics product. It does not include, and will not grow, a backend, storage, dashboards, session recording, heatmaps, analytics ingestion, or any transport for the events it produces. Sending them somewhere is one line of your code, and every product needs that line to be different.

Roadmap

Ideas that are deliberately not in v1:

  • selector-based observation of elements added later
  • an aggregate frustration score across detectors
  • React, Vue and Svelte adapters
  • a visual debug overlay
  • fetch instrumentation for more accurate dead clicks
  • hesitation through keyboard focus, and a touch equivalent

Design notes

docs/design.md records why the library is built this way: the precise contract of each detector, the alternatives that were rejected, and what was left out of v1 on purpose.

License

MIT