React hooks for detecting user frustration, confusion, and engagement signals in real time.
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.
npm install react-sentiments
# or
yarn add react-sentiments
# or
pnpm add react-sentimentsRequirements: React 16.8+
| 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 |
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 }
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 }
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 |
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 |
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 }
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 }
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 }
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 }
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 }
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 }
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>
);
}All hooks are fully typed. You can import option and return types directly:
import type {
UseRageClickOptions,
UseHesitationOptions,
InputHesitationInfo,
FrustrationSignal,
} from "react-sentiments";MIT