Skip to content

Unfathomable-08/react-sentiments

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors