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

react-sentiments

v0.1.3

Published

React hooks for detecting user frustration, confusion and engagement signals

Readme

react-sentiments

React hooks for detecting user frustration, confusion, and engagement signals in real time.

npm version bundle size license

Most analytics tools tell you what users clicked. react-sentiments tells you how they felt frustrated, confused, hesitant, or engaged. So you can act on it in real time.


Installation

npm install react-sentiments
# or
yarn add react-sentiments
# or
pnpm add react-sentiments

Requirements: React 16.8+


Hooks

| Hook | What it detects | |---|---| | useRageClick | Rapid repeated clicks => frustration | | useHesitation | Long hover before click => uncertainty | | useDeadClick | Clicks on non-interactive elements => broken UI | | useExitIntent | Mouse heading to browser chrome => about to leave | | useEngagementTime | Active time on page (tab focused + not idle) | | useScrollDepth | How far down the page they actually scrolled | | useInputHesitation | Types, pauses, deletes => unsure what to enter | | useCopyIntent | Selects or copies text => content engagement | | useReread | Scrolls back to re-read a section => confused or interested | | useSessionFrustration | Aggregated frustration score (0–100) from all signals |


Usage

useRageClick

Detects when a user clicks the same element rapidly, a classic sign of frustration.

import { useRageClick } from "react-sentiments";

function SubmitButton() {
  const { onClick } = useRageClick({
    threshold: 3,       // clicks needed to trigger
    timeWindow: 1000,   // within this many ms
    onRageClick: (count) => {
      console.log(`Rage clicked ${count} times!`);
      showHelpTooltip("Having trouble? Try refreshing the page.");
    },
  });

  return <button onClick={onClick}>Submit</button>;
}

Options

| Option | Type | Default | Description | |---|---|---|---| | threshold | number | 3 | Clicks needed to trigger | | timeWindow | number | 1000 | Time window in ms | | onRageClick | (count: number) => void | required | Fired when threshold is reached |

Returns: { onClick }


useHesitation

Detects when a user hovers over an element for too long before clicking, a signal of uncertainty.

import { useHesitation } from "react-sentiments";

function PricingButton() {
  const { onMouseEnter, onMouseLeave, onClick } = useHesitation({
    threshold: 2000, // ms of hover before it's hesitation
    onHesitation: (dwellTime) => {
      console.log(`User hesitated for ${dwellTime}ms`);
      showPricingTooltip("This is our most popular plan!");
    },
  });

  return (
    <button {...{ onMouseEnter, onMouseLeave, onClick }}>
      Buy Now — $49/mo
    </button>
  );
}

Options

| Option | Type | Default | Description | |---|---|---|---| | threshold | number | 2000 | Dwell time in ms before hesitation | | onHesitation | (dwellTime: number) => void | required | Fired on click if hovered too long |

Returns: { onMouseEnter, onMouseLeave, onClick }


useDeadClick

Detects clicks on non-interactive elements, the user thinks something should be clickable but it isn't.

import { useDeadClick } from "react-sentiments";

function App() {
  useDeadClick({
    scope: "#main-content",  // only monitor this area
    onDeadClick: (element) => {
      console.log("Dead click on:", element.tagName, element.className);
      analytics.track("dead_click", { element: element.outerHTML });
    },
  });

  return <main id="main-content">...</main>;
}

Options

| Option | Type | Default | Description | |---|---|---|---| | scope | string | "body" | CSS selector to scope monitoring | | onDeadClick | (element: HTMLElement) => void | required | Fired on dead click |


useExitIntent

Detects when the user moves their mouse toward the top of the viewport, they're about to leave.

import { useExitIntent } from "react-sentiments";

function Page() {
  useExitIntent({
    threshold: 20,  // px from top edge
    once: true,     // fire once per session
    onExitIntent: () => {
      showRetentionModal("Wait! Here's 10% off before you go.");
    },
  });

  return <div>...</div>;
}

Options

| Option | Type | Default | Description | |---|---|---|---| | threshold | number | 20 | Distance from top in px to trigger | | once | boolean | true | Fire only once per page load | | onExitIntent | () => void | required | Fired when exit intent detected |


useEngagementTime

Tracks how long a user is actively engaged, tab must be focused and user must not be idle.

import { useEngagementTime } from "react-sentiments";

function Article() {
  const { engagementTime, isActive } = useEngagementTime({
    idleTimeout: 30000, // stop counting after 30s of inactivity
  });

  // Send to analytics when user leaves
  useEffect(() => {
    return () => analytics.track("engagement", { seconds: engagementTime });
  }, [engagementTime]);

  return (
    <div>
      <article>...</article>
      <p>Active for {engagementTime}s {isActive ? "🟢" : "⚪"}</p>
    </div>
  );
}

Options

| Option | Type | Default | Description | |---|---|---|---| | idleTimeout | number | 30000 | Inactivity ms before stopping the counter |

Returns: { engagementTime, isActive }


useScrollDepth

Tracks the maximum scroll depth as a percentage, with optional milestone callbacks.

import { useScrollDepth } from "react-sentiments";

function BlogPost() {
  const { scrollDepth } = useScrollDepth({
    milestones: [25, 50, 75, 100],
    onMilestone: (pct) => {
      analytics.track("scroll_depth", { percentage: pct });
    },
  });

  return (
    <div>
      <article>...</article>
      <p>Read {scrollDepth}% of this post</p>
    </div>
  );
}

Options

| Option | Type | Default | Description | |---|---|---|---| | milestones | number[] | [25, 50, 75, 100] | Percentages to fire callback at | | onMilestone | (pct: number) => void | — | Fired when each milestone is crossed |

Returns: { scrollDepth }


useInputHesitation

Detects uncertainty in form fields, user types, long pause, then deletes.

import { useInputHesitation } from "react-sentiments";

function SignupForm() {
  const { onChange } = useInputHesitation({
    pauseThreshold: 2000,
    onHesitation: ({ value, pauseDuration, deletedAfterPause }) => {
      if (deletedAfterPause) {
        showFieldHint("Not sure what to enter? Here's an example.");
      }
      analytics.track("input_hesitation", { field: "email", pauseDuration });
    },
  });

  return <input type="email" onChange={onChange} placeholder="Email" />;
}

Options

| Option | Type | Default | Description | |---|---|---|---| | pauseThreshold | number | 2000 | Pause in ms before hesitation fires | | onHesitation | (info: InputHesitationInfo) => void | required | Fired on hesitation |

InputHesitationInfo

| Field | Type | Description | |---|---|---| | value | string | Field value at time of hesitation | | pauseDuration | number | How long the pause was in ms | | deletedAfterPause | boolean | Whether user deleted text after pausing |

Returns: { onChange }


useCopyIntent

Detects when a user selects text, and whether they followed through with an actual copy.

import { useCopyIntent } from "react-sentiments";

function CodeBlock({ code }: { code: string }) {
  const { onMouseUp, onCopy } = useCopyIntent({
    onSelect: (text) => analytics.track("text_selected", { text }),
    onCopy: (text) => analytics.track("text_copied", { text }),
  });

  return <pre onMouseUp={onMouseUp} onCopy={onCopy}>{code}</pre>;
}

Options

| Option | Type | Default | Description | |---|---|---|---| | onSelect | (text: string) => void | — | Fired when user selects text | | onCopy | (text: string) => void | — | Fired when user copies text |

Returns: { onMouseUp, onCopy }


useReread

Detects when a user scrolls back to re-read a section, could mean confusion or high interest.

import { useReread } from "react-sentiments";

function ComplexSection() {
  const { ref, rereadCount } = useReread({
    threshold: 2,
    onReread: (count) => {
      if (count >= 2) showExplainerTooltip("Need a hand with this section?");
      analytics.track("section_reread", { count });
    },
  });

  return (
    <section ref={ref}>
      <h2>How our pricing works</h2>
      <p>...</p>
    </section>
  );
}

Options

| Option | Type | Default | Description | |---|---|---|---| | threshold | number | 2 | Number of rereads before firing callback | | onReread | (count: number) => void | — | Fired when threshold is crossed |

Returns: { ref, rereadCount }


useSessionFrustration

Aggregates all frustration signals into a single score from 0–100. Use it alongside the other hooks.

import {
  useSessionFrustration,
  useRageClick,
  useExitIntent,
  useDeadClick,
} from "react-sentiments";

function App() {
  const { score, signals, addSignal, reset } = useSessionFrustration();

  useRageClick({ onRageClick: () => addSignal("rageClick") });
  useExitIntent({ onExitIntent: () => addSignal("exitIntent") });
  useDeadClick({ onDeadClick: () => addSignal("deadClick") });

  useEffect(() => {
    if (score >= 60) {
      openLiveChat("Looks like you're having trouble — can we help?");
    }
  }, [score]);

  return (
    <div>
      <p>Frustration score: {score}/100</p>
      <p>Signals: {signals.join(", ")}</p>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Signal weights

| Signal | Weight | |---|---| | rageClick | 25 | | exitIntent | 20 | | deadClick | 15 | | hesitation | 10 | | inputHesitation | 10 | | reread | 5 |

Returns: { score, signals, addSignal, reset }


Real world example — putting it all together

import {
  useSessionFrustration,
  useRageClick,
  useExitIntent,
  useDeadClick,
  useScrollDepth,
  useEngagementTime,
} from "react-sentiments";

function CheckoutPage() {
  const { score, addSignal } = useSessionFrustration();
  const { engagementTime } = useEngagementTime();
  const { scrollDepth } = useScrollDepth({
    onMilestone: (pct) => analytics.track("scroll", { pct }),
  });

  useRageClick({
    onRageClick: (count) => {
      addSignal("rageClick");
      analytics.track("rage_click", { count });
    },
  });

  useExitIntent({
    onExitIntent: () => {
      addSignal("exitIntent");
      if (score > 40) showExitModal("Need help completing your order?");
    },
  });

  useDeadClick({
    scope: "#checkout-form",
    onDeadClick: (el) => {
      addSignal("deadClick");
      console.warn("Dead click on:", el);
    },
  });

  return (
    <div>
      <form id="checkout-form">...</form>
      {/* Debug panel — remove in production */}
      <pre>Score: {score} | Time: {engagementTime}s | Depth: {scrollDepth}%</pre>
    </div>
  );
}

TypeScript

All hooks are fully typed. You can import option and return types directly:

import type {
  UseRageClickOptions,
  UseHesitationOptions,
  InputHesitationInfo,
  FrustrationSignal,
} from "react-sentiments";

License

MIT